@danhachuel/thunderbolt 0.3.23 → 0.3.25

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,7 +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
+ from hermes_ui.drafts import list_drafts, save_draft
26
26
  from hermes_ui.automation_worker import load_worker_status
27
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
28
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
@@ -46,8 +46,17 @@ from hermes_ui.script_documents import list_script_documents, read_script_docume
46
46
  from hermes_ui.script_generation import generate_script_document
47
47
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
48
48
  from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
49
- from hermes_ui.thumbnails import list_thumbnail_tasks, regenerate_thumbnail
50
- from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel, generate_video_keywords
49
+ from hermes_ui.thumbnails import (
50
+ generate_thumbnail_for_task,
51
+ list_thumbnail_tasks,
52
+ regenerate_thumbnail,
53
+ regenerate_thumbnail_lettering,
54
+ regenerate_thumbnail_prompt,
55
+ regenerate_thumbnail_prompt_and_image,
56
+ upload_thumbnail_image,
57
+ )
58
+ from hermes_ui.draft_video import DRAFT_SETTING_SECTIONS, missing_content_fields, missing_setting_sections, normalise_saved_script, setting_widget_suffixes
59
+ from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_thumbnail_prompt, generate_topic_for_channel, generate_video_keywords
51
60
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
52
61
  from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
53
62
  from integrations.postiz import PostizAdapter
@@ -648,6 +657,7 @@ def _save_pipeline_draft_callback(
648
657
  "video_subject": subject,
649
658
  "video_script": script,
650
659
  "video_keywords": keywords,
660
+ "generation_settings": dict(st.session_state.get(f"{prefix}_generation_settings") or {}),
651
661
  "summary": str((st.session_state.get("script_draft_summary") if is_script_draft else "") or "").strip(),
652
662
  "document_type": document_type,
653
663
  "language": str(st.session_state.get(f"{prefix}_script_language") or "").strip(),
@@ -712,134 +722,141 @@ def render_video_generation_settings(
712
722
  channel: dict[str, Any] | None = None,
713
723
  generate_content_callback: Any | None = None,
714
724
  save_draft_callback: Any | None = None,
725
+ sections: set[str] | None = None,
726
+ include_content: bool = True,
715
727
  ) -> dict[str, Any]:
716
- """Render the shared MoneyPrinter-style settings and return a serializable payload."""
728
+ """Render shared settings, optionally limiting the visible video sections."""
717
729
  settings: dict[str, Any] = {}
718
- st.markdown("### Video Subject Settings")
719
- subject_cols = st.columns(2)
720
- with subject_cols[0]:
721
- settings["video_subject"] = st.text_input(
722
- "Video Subject",
723
- value=str(st.session_state.get(f"{prefix}_video_subject", "")),
724
- key=f"{prefix}_video_subject",
725
- placeholder="Ex.: How AI is changing everyday life",
726
- )
727
- normalized_current_language = "music" if str(current_language or "").strip().casefold() in {"music", "00 – apenas música de fundo (sem falas)", "00 - apenas música de fundo (sem falas)"} else language_code(current_language)
728
- settings["script_language"] = st.selectbox(
729
- "Script Language",
730
- VIDEO_LANGUAGE_SELECTION_OPTIONS,
731
- index=VIDEO_LANGUAGE_SELECTION_OPTIONS.index(normalized_current_language) if normalized_current_language in VIDEO_LANGUAGE_SELECTION_OPTIONS else 0,
732
- format_func=video_language_label,
733
- key=f"{prefix}_script_language",
734
- )
735
- with subject_cols[1]:
736
- with st.expander("Advanced Script Settings", expanded=False):
737
- settings["script_structure_notes"] = st.text_area(
738
- "Estrutura e notas opcionais",
739
- value=str(st.session_state.get(f"{prefix}_script_structure_notes", "")),
740
- key=f"{prefix}_script_structure_notes",
741
- height=100,
742
- placeholder="Ex.: gancho forte, 6 cenas, narração documental…",
730
+ visible_sections = sections if sections is not None else {"Configurações de vídeo", "Configurações de áudio", "Configurações de legendas"}
731
+
732
+ if include_content:
733
+ st.markdown("### Video Subject Settings")
734
+ subject_cols = st.columns(2)
735
+ with subject_cols[0]:
736
+ settings["video_subject"] = st.text_input(
737
+ "Video Subject",
738
+ value=str(st.session_state.get(f"{prefix}_video_subject", "")),
739
+ key=f"{prefix}_video_subject",
740
+ placeholder="Ex.: How AI is changing everyday life",
743
741
  )
744
- settings["generate_script_with_ai"] = st.checkbox("Generate Script & Keywords with AI", value=True, key=f"{prefix}_generate_script_with_ai")
745
- settings["video_script"] = st.text_area(
746
- "Video Script (Optional)",
747
- value=str(st.session_state.get(f"{prefix}_video_script", "")),
748
- key=f"{prefix}_video_script",
749
- height=130,
750
- )
751
- settings["video_keywords"] = st.text_area(
752
- "Video Keywords (English, Optional)",
753
- value=str(st.session_state.get(f"{prefix}_video_keywords", "")),
754
- key=f"{prefix}_video_keywords",
755
- height=90,
756
- )
757
- if generate_content_callback is not None:
758
- st.button(
759
- "Gerar tópico, roteiro e palavras-chave com IA",
760
- key=f"{prefix}_generate_video_content",
761
- use_container_width=True,
762
- type="secondary",
763
- icon=":material/auto_awesome:",
764
- on_click=generate_content_callback,
742
+ normalized_current_language = "music" if str(current_language or "").strip().casefold() in {"music", "00 apenas música de fundo (sem falas)", "00 - apenas música de fundo (sem falas)"} else language_code(current_language)
743
+ settings["script_language"] = st.selectbox(
744
+ "Script Language",
745
+ VIDEO_LANGUAGE_SELECTION_OPTIONS,
746
+ index=VIDEO_LANGUAGE_SELECTION_OPTIONS.index(normalized_current_language) if normalized_current_language in VIDEO_LANGUAGE_SELECTION_OPTIONS else 0,
747
+ format_func=video_language_label,
748
+ key=f"{prefix}_script_language",
749
+ )
750
+ with subject_cols[1]:
751
+ with st.expander("Advanced Script Settings", expanded=False):
752
+ settings["script_structure_notes"] = st.text_area(
753
+ "Estrutura e notas opcionais",
754
+ value=str(st.session_state.get(f"{prefix}_script_structure_notes", "")),
755
+ key=f"{prefix}_script_structure_notes",
756
+ height=100,
757
+ placeholder="Ex.: gancho forte, 6 cenas, narração documental…",
758
+ )
759
+ settings["generate_script_with_ai"] = st.checkbox("Generate Script & Keywords with AI", value=True, key=f"{prefix}_generate_script_with_ai")
760
+ settings["video_script"] = st.text_area(
761
+ "Video Script (Optional)",
762
+ value=str(st.session_state.get(f"{prefix}_video_script", "")),
763
+ key=f"{prefix}_video_script",
764
+ height=130,
765
765
  )
766
- if st.session_state.get(f"{prefix}_generate_content_notice"):
767
- st.success(st.session_state[f"{prefix}_generate_content_notice"])
768
- if st.session_state.get(f"{prefix}_generate_content_error"):
769
- st.error(st.session_state[f"{prefix}_generate_content_error"])
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,
766
+ settings["video_keywords"] = st.text_area(
767
+ "Video Keywords (English, Optional)",
768
+ value=str(st.session_state.get(f"{prefix}_video_keywords", "")),
769
+ key=f"{prefix}_video_keywords",
770
+ height=90,
779
771
  )
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
-
785
- st.markdown("### Video Settings")
786
- video_cols = st.columns(2)
787
- with video_cols[0]:
788
- settings["video_source"] = st.selectbox("Video Source", WIDE_STYLE_OPTIONS, key=f"{prefix}_video_source")
789
- if settings["video_source"] == "full_ia":
790
- settings["style_ia"] = st.selectbox("Estilo IA", AI_STYLE_OPTIONS, key=f"{prefix}_style_ia")
791
- else:
792
- settings["style_ia"] = ""
793
- settings["video_format"] = st.selectbox("Formato", VIDEO_FORMAT_OPTIONS, key=f"{prefix}_video_format")
794
- settings["video_concatenation_mode"] = st.selectbox("Video Concatenation Mode", VIDEO_CONCATENATION_OPTIONS, key=f"{prefix}_video_concatenation")
795
- settings["match_visuals_to_script_order"] = st.checkbox("Match Visuals to Script Order", value=False, key=f"{prefix}_match_visuals")
796
- settings["video_transition_mode"] = st.selectbox("Video Transition Mode", VIDEO_TRANSITION_OPTIONS, key=f"{prefix}_video_transition")
797
- with video_cols[1]:
798
- settings["video_aspect_ratio"] = st.selectbox("Video Aspect Ratio", ["Portrait 9:16", "Landscape 16:9", "Square 1:1"], key=f"{prefix}_video_aspect_ratio")
799
- settings["maximum_clip_duration"] = st.selectbox("Maximum Clip Duration (seconds)", [3, 5, 8, 10, 15], key=f"{prefix}_maximum_clip_duration")
800
- settings["videos_per_run"] = st.selectbox("Videos per Run", list(range(1, 11)), key=f"{prefix}_videos_per_run")
801
- settings["video_encoder"] = st.selectbox("Video Encoder", VIDEO_ENCODER_OPTIONS, key=f"{prefix}_video_encoder")
802
-
803
- st.markdown("### Audio Settings")
804
- audio_cols = st.columns(2)
805
- with audio_cols[0]:
806
- settings["voiceover_mode"] = st.radio("Voiceover Mode", VOICEOVER_MODE_OPTIONS, horizontal=True, key=f"{prefix}_voiceover_mode")
807
- settings["voiceover_service"] = st.selectbox("Voiceover Service", VOICEOVER_SERVICE_OPTIONS, key=f"{prefix}_voiceover_service")
808
- if channel is not None:
809
- channel_id = str(channel.get("id") or channel.get("name") or "")
810
- channel_voice = str(channel.get("default_voice") or channel.get("voice") or "").strip()
811
- channel_state_key = f"{prefix}_voice_channel_id"
812
- if st.session_state.get(channel_state_key) != channel_id:
813
- st.session_state[f"{prefix}_voice"] = channel_voice
814
- st.session_state[channel_state_key] = channel_id
815
- current_voice = str(st.session_state.get(f"{prefix}_voice", ""))
816
- voice_options = voice_catalog(current_voice)
817
- settings["voice"] = st.selectbox("Voice (match script language)", voice_options, format_func=lambda value: value or "Sem voz seleccionada", key=f"{prefix}_voice")
818
- volume_speed_cols = st.columns(2)
819
- with volume_speed_cols[0]:
820
- settings["voiceover_volume"] = st.selectbox("Voiceover Volume", VOICEOVER_VOLUME_OPTIONS, index=VOICEOVER_VOLUME_OPTIONS.index("100%"), key=f"{prefix}_voiceover_volume")
821
- with volume_speed_cols[1]:
822
- settings["voiceover_speed"] = st.selectbox("Voiceover Speed", VOICEOVER_SPEED_OPTIONS, index=VOICEOVER_SPEED_OPTIONS.index("1.0x"), key=f"{prefix}_voiceover_speed")
823
- st.button("Preview Voice", key=f"{prefix}_preview_voice", disabled=True, help="A pré-visualização de voz será ligada ao provider configurado.")
824
- with audio_cols[1]:
825
- settings["background_music_source"] = st.selectbox("Background Music Source", BACKGROUND_MUSIC_SOURCE_OPTIONS, index=3, key=f"{prefix}_background_music_source")
826
- settings["background_music_volume"] = st.selectbox("Background Music Volume", BACKGROUND_MUSIC_VOLUME_OPTIONS, index=2, key=f"{prefix}_background_music_volume")
827
-
828
- st.markdown("### Subtitle Settings")
829
- subtitle_cols = st.columns(2)
830
- with subtitle_cols[0]:
831
- settings["enable_subtitles"] = st.checkbox("Enable Subtitles", value=True, key=f"{prefix}_enable_subtitles")
832
- settings["subtitle_font"] = st.selectbox("Font", SUBTITLE_FONT_OPTIONS, key=f"{prefix}_subtitle_font")
833
- settings["subtitle_position"] = st.selectbox("Position", SUBTITLE_POSITION_OPTIONS, key=f"{prefix}_subtitle_position")
834
- settings["subtitle_color"] = st.color_picker("Color", "#FFFFFF", key=f"{prefix}_subtitle_color")
835
- settings["subtitle_background"] = st.checkbox("Background", value=True, key=f"{prefix}_subtitle_background")
836
- settings["subtitle_background_color"] = st.color_picker("Background Color", "#000000", key=f"{prefix}_subtitle_background_color")
837
- settings["subtitle_rounded_background"] = st.checkbox("Rounded Background", value=False, key=f"{prefix}_subtitle_rounded_background")
838
- with subtitle_cols[1]:
839
- settings["subtitle_font_size"] = st.slider("Font Size", min_value=12, max_value=96, value=60, key=f"{prefix}_subtitle_font_size")
840
- settings["subtitle_outline"] = st.color_picker("Outline", "#000000", key=f"{prefix}_subtitle_outline")
841
- settings["subtitle_outline_width"] = st.slider("Outline Width", min_value=0.0, max_value=5.0, value=1.5, step=0.25, key=f"{prefix}_subtitle_outline_width")
842
- st.button("Restore Subtitle Defaults", key=f"{prefix}_restore_subtitle_defaults", disabled=True, help="Os valores predefinidos já estão activos nesta configuração.")
772
+ if generate_content_callback is not None:
773
+ st.button(
774
+ "Gerar tópico, roteiro e palavras-chave com IA",
775
+ key=f"{prefix}_generate_video_content",
776
+ use_container_width=True,
777
+ type="secondary",
778
+ icon=":material/auto_awesome:",
779
+ on_click=generate_content_callback,
780
+ )
781
+ if st.session_state.get(f"{prefix}_generate_content_notice"):
782
+ st.success(st.session_state[f"{prefix}_generate_content_notice"])
783
+ if st.session_state.get(f"{prefix}_generate_content_error"):
784
+ st.error(st.session_state[f"{prefix}_generate_content_error"])
785
+ if save_draft_callback is not None:
786
+ st.button(
787
+ "Salvar rascunho",
788
+ key=f"{prefix}_save_draft",
789
+ use_container_width=True,
790
+ type="secondary",
791
+ icon=":material/save:",
792
+ on_click=save_draft_callback,
793
+ )
794
+ if st.session_state.get(f"{prefix}_save_draft_notice"):
795
+ st.success(st.session_state[f"{prefix}_save_draft_notice"])
796
+ if st.session_state.get(f"{prefix}_save_draft_error"):
797
+ st.error(st.session_state[f"{prefix}_save_draft_error"])
798
+
799
+ if "Configurações de vídeo" in visible_sections:
800
+ st.markdown("### Video Settings")
801
+ video_cols = st.columns(2)
802
+ with video_cols[0]:
803
+ settings["video_source"] = st.selectbox("Video Source", WIDE_STYLE_OPTIONS, key=f"{prefix}_video_source")
804
+ if settings["video_source"] == "full_ia":
805
+ settings["style_ia"] = st.selectbox("Estilo IA", AI_STYLE_OPTIONS, key=f"{prefix}_style_ia")
806
+ else:
807
+ settings["style_ia"] = ""
808
+ settings["video_format"] = st.selectbox("Formato", VIDEO_FORMAT_OPTIONS, key=f"{prefix}_video_format")
809
+ settings["video_concatenation_mode"] = st.selectbox("Video Concatenation Mode", VIDEO_CONCATENATION_OPTIONS, key=f"{prefix}_video_concatenation")
810
+ settings["match_visuals_to_script_order"] = st.checkbox("Match Visuals to Script Order", value=False, key=f"{prefix}_match_visuals")
811
+ settings["video_transition_mode"] = st.selectbox("Video Transition Mode", VIDEO_TRANSITION_OPTIONS, key=f"{prefix}_video_transition")
812
+ with video_cols[1]:
813
+ settings["video_aspect_ratio"] = st.selectbox("Video Aspect Ratio", ["Portrait 9:16", "Landscape 16:9", "Square 1:1"], key=f"{prefix}_video_aspect_ratio")
814
+ settings["maximum_clip_duration"] = st.selectbox("Maximum Clip Duration (seconds)", [3, 5, 8, 10, 15], key=f"{prefix}_maximum_clip_duration")
815
+ settings["videos_per_run"] = st.selectbox("Videos per Run", list(range(1, 11)), key=f"{prefix}_videos_per_run")
816
+ settings["video_encoder"] = st.selectbox("Video Encoder", VIDEO_ENCODER_OPTIONS, key=f"{prefix}_video_encoder")
817
+
818
+ if "Configurações de áudio" in visible_sections:
819
+ st.markdown("### Audio Settings")
820
+ audio_cols = st.columns(2)
821
+ with audio_cols[0]:
822
+ settings["voiceover_mode"] = st.radio("Voiceover Mode", VOICEOVER_MODE_OPTIONS, horizontal=True, key=f"{prefix}_voiceover_mode")
823
+ settings["voiceover_service"] = st.selectbox("Voiceover Service", VOICEOVER_SERVICE_OPTIONS, key=f"{prefix}_voiceover_service")
824
+ if channel is not None:
825
+ channel_id = str(channel.get("id") or channel.get("name") or "")
826
+ channel_voice = str(channel.get("default_voice") or channel.get("voice") or "").strip()
827
+ channel_state_key = f"{prefix}_voice_channel_id"
828
+ if st.session_state.get(channel_state_key) != channel_id:
829
+ st.session_state[f"{prefix}_voice"] = channel_voice
830
+ st.session_state[channel_state_key] = channel_id
831
+ current_voice = str(st.session_state.get(f"{prefix}_voice", ""))
832
+ voice_options = voice_catalog(current_voice)
833
+ settings["voice"] = st.selectbox("Voice (match script language)", voice_options, format_func=lambda value: value or "Sem voz seleccionada", key=f"{prefix}_voice")
834
+ volume_speed_cols = st.columns(2)
835
+ with volume_speed_cols[0]:
836
+ settings["voiceover_volume"] = st.selectbox("Voiceover Volume", VOICEOVER_VOLUME_OPTIONS, index=VOICEOVER_VOLUME_OPTIONS.index("100%"), key=f"{prefix}_voiceover_volume")
837
+ with volume_speed_cols[1]:
838
+ settings["voiceover_speed"] = st.selectbox("Voiceover Speed", VOICEOVER_SPEED_OPTIONS, index=VOICEOVER_SPEED_OPTIONS.index("1.0x"), key=f"{prefix}_voiceover_speed")
839
+ st.button("Preview Voice", key=f"{prefix}_preview_voice", disabled=True, help="A pré-visualização de voz será ligada ao provider configurado.")
840
+ with audio_cols[1]:
841
+ settings["background_music_source"] = st.selectbox("Background Music Source", BACKGROUND_MUSIC_SOURCE_OPTIONS, index=3, key=f"{prefix}_background_music_source")
842
+ settings["background_music_volume"] = st.selectbox("Background Music Volume", BACKGROUND_MUSIC_VOLUME_OPTIONS, index=2, key=f"{prefix}_background_music_volume")
843
+
844
+ if "Configurações de legendas" in visible_sections:
845
+ st.markdown("### Subtitle Settings")
846
+ subtitle_cols = st.columns(2)
847
+ with subtitle_cols[0]:
848
+ settings["enable_subtitles"] = st.checkbox("Enable Subtitles", value=True, key=f"{prefix}_enable_subtitles")
849
+ settings["subtitle_font"] = st.selectbox("Font", SUBTITLE_FONT_OPTIONS, key=f"{prefix}_subtitle_font")
850
+ settings["subtitle_position"] = st.selectbox("Position", SUBTITLE_POSITION_OPTIONS, key=f"{prefix}_subtitle_position")
851
+ settings["subtitle_color"] = st.color_picker("Color", "#FFFFFF", key=f"{prefix}_subtitle_color")
852
+ settings["subtitle_background"] = st.checkbox("Background", value=True, key=f"{prefix}_subtitle_background")
853
+ settings["subtitle_background_color"] = st.color_picker("Background Color", "#000000", key=f"{prefix}_subtitle_background_color")
854
+ settings["subtitle_rounded_background"] = st.checkbox("Rounded Background", value=False, key=f"{prefix}_subtitle_rounded_background")
855
+ with subtitle_cols[1]:
856
+ settings["subtitle_font_size"] = st.slider("Font Size", min_value=12, max_value=96, value=60, key=f"{prefix}_subtitle_font_size")
857
+ settings["subtitle_outline"] = st.color_picker("Outline", "#000000", key=f"{prefix}_subtitle_outline")
858
+ settings["subtitle_outline_width"] = st.slider("Outline Width", min_value=0.0, max_value=5.0, value=1.5, step=0.25, key=f"{prefix}_subtitle_outline_width")
859
+ st.button("Restore Subtitle Defaults", key=f"{prefix}_restore_subtitle_defaults", disabled=True, help="Os valores predefinidos já estão activos nesta configuração.")
843
860
  return settings
844
861
 
845
862
 
@@ -1748,9 +1765,191 @@ def render_channels():
1748
1765
  render_channel_videos(channel)
1749
1766
 
1750
1767
 
1768
+ def _saved_video_draft_records() -> list[dict[str, Any]]:
1769
+ """Return saved video scripts and local pipeline drafts in one selectable list."""
1770
+ records: list[dict[str, Any]] = []
1771
+ for source, source_records in (("history", list_script_documents()), ("draft", list_drafts())):
1772
+ for raw_record in source_records:
1773
+ if not isinstance(raw_record, dict):
1774
+ continue
1775
+ document_type = str(raw_record.get("document_type") or "video_script").strip()
1776
+ draft_kind = str(raw_record.get("draft_kind") or "").strip()
1777
+ if document_type not in {"", "video_script"}:
1778
+ continue
1779
+ if source == "draft" and draft_kind not in {"", "video", "script"}:
1780
+ continue
1781
+ if source == "draft" and not draft_kind and str(raw_record.get("page") or "") == "Criação de Músicas":
1782
+ continue
1783
+ if source == "history":
1784
+ try:
1785
+ content = read_script_document(raw_record)
1786
+ except (OSError, UnicodeError):
1787
+ content = ""
1788
+ else:
1789
+ content = str(raw_record.get("content") or "")
1790
+ record = normalise_saved_script({**raw_record, "source": source, "source_label": "Histórico guardado" if source == "history" else "Rascunho local"}, content)
1791
+ record["resume_id"] = f"{source}:{raw_record.get('id') or raw_record.get('filename') or len(records)}"
1792
+ records.append(record)
1793
+ return records
1794
+
1795
+
1796
+ def _seed_resume_video_settings(record: dict[str, Any]) -> None:
1797
+ """Load one persisted record into the namespaced resume widgets."""
1798
+ resume_id = str(record.get("resume_id") or "")
1799
+ if st.session_state.get("new_video_resume_loaded_id") == resume_id:
1800
+ return
1801
+ generation_settings = record.get("generation_settings") if isinstance(record.get("generation_settings"), dict) else {}
1802
+ st.session_state["new_video_resume_sections"] = missing_setting_sections(generation_settings)
1803
+ for suffix in setting_widget_suffixes():
1804
+ if suffix in generation_settings:
1805
+ st.session_state[f"new_video_resume_{suffix}"] = generation_settings[suffix]
1806
+ st.session_state["new_video_resume_loaded_id"] = resume_id
1807
+
1808
+
1809
+ def _create_video_task_from_saved_script(record: dict[str, Any], channel: dict[str, Any], settings: dict[str, Any]) -> list[dict[str, Any]]:
1810
+ """Create a normal pipeline task while preserving the saved script as an override."""
1811
+ subject = str(record.get("video_subject") or "").strip()
1812
+ script = str(record.get("video_script") or "").strip()
1813
+ keywords = str(record.get("video_keywords") or "").strip()
1814
+ settings = {
1815
+ **(record.get("generation_settings") if isinstance(record.get("generation_settings"), dict) else {}),
1816
+ **settings,
1817
+ "video_subject": subject,
1818
+ "video_script": script,
1819
+ "video_keywords": keywords,
1820
+ "script_language": str(settings.get("script_language") or record.get("language") or channel.get("language") or "pt"),
1821
+ "generate_script_with_ai": False,
1822
+ }
1823
+ style_label = str(settings.get("video_source") or "Pexels/Pixabay")
1824
+ style = {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}.get(style_label, style_label)
1825
+ blueprint_id = str(record.get("blueprint_id") or channel.get("default_blueprint_id") or channel.get("blueprint_id") or "")
1826
+ blueprint_name = str(record.get("blueprint_name") or blueprint_id or "SEM BLUEPRINT CONFIGURADO")
1827
+ payload = {
1828
+ "topic": subject,
1829
+ "title": str(record.get("title") or subject).strip(),
1830
+ "topic_source": "saved_script",
1831
+ "language": settings["script_language"],
1832
+ "format": settings.get("video_format", "wide"),
1833
+ "style_wide": style,
1834
+ "style_ia": settings.get("style_ia", ""),
1835
+ "music_mode": style == "music",
1836
+ "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"),
1837
+ "voice": str(settings.get("voice") or channel.get("default_voice") or channel.get("voice") or ""),
1838
+ "blueprint_id": blueprint_id,
1839
+ "blueprint_name": blueprint_name,
1840
+ "generation_settings": settings,
1841
+ }
1842
+ batch = create_batch(
1843
+ "single",
1844
+ [str(channel.get("id") or "")],
1845
+ subject,
1846
+ 1,
1847
+ {
1848
+ **payload,
1849
+ "topic_source": "saved_script",
1850
+ "channel_payloads": {str(channel.get("id") or ""): payload},
1851
+ },
1852
+ )
1853
+ return create_tasks_for_batch(batch)
1854
+
1855
+
1856
+ def render_video_from_draft() -> None:
1857
+ """Render the continuation flow for saved scripts and local pipeline drafts."""
1858
+ st.subheader("Roteiros guardados")
1859
+ st.caption("Seleccione um roteiro guardado para continuar a criação do vídeo sem perder o conteúdo já preparado.")
1860
+ records = _saved_video_draft_records()
1861
+ if not records:
1862
+ st.info("Ainda não existem roteiros guardados.")
1863
+ return
1864
+
1865
+ record_by_id = {str(record["resume_id"]): record for record in records}
1866
+ selected_id = st.selectbox(
1867
+ "Seleccione um roteiro",
1868
+ list(record_by_id),
1869
+ format_func=lambda identifier: f"{record_by_id[identifier].get('title') or 'Roteiro sem título'} · {record_by_id[identifier].get('source_label') or 'Rascunho'}",
1870
+ key="new_video_resume_selected",
1871
+ )
1872
+ record = record_by_id[selected_id]
1873
+ _seed_resume_video_settings(record)
1874
+ with st.container(border=True):
1875
+ st.markdown(f"**Video Subject:** {record.get('video_subject') or '—'}")
1876
+ st.caption(f"{record.get('source_label') or 'Rascunho'} · {record.get('channel_name') or 'Documento independente'} · Blueprint: {record.get('blueprint_name') or '—'}")
1877
+ if record.get("video_script"):
1878
+ st.text_area("Video Script (Optional)", value=str(record["video_script"]), height=150, disabled=True, key=f"resume_preview_script_{selected_id}")
1879
+ if record.get("video_keywords"):
1880
+ st.caption(f"**Video Keywords:** {record['video_keywords']}")
1881
+
1882
+ content_missing = missing_content_fields(record)
1883
+ if content_missing:
1884
+ st.warning(f"Conteúdo em falta: {', '.join(content_missing)}. Volte a Roteiros e guarde tópico, roteiro e palavras-chave antes de continuar.")
1885
+ return
1886
+
1887
+ persisted_settings = record.get("generation_settings") if isinstance(record.get("generation_settings"), dict) else {}
1888
+ missing_sections = missing_setting_sections(persisted_settings)
1889
+ if missing_sections:
1890
+ selected_sections = set(
1891
+ st.multiselect(
1892
+ "Configurações a completar",
1893
+ list(DRAFT_SETTING_SECTIONS),
1894
+ default=missing_sections,
1895
+ key="new_video_resume_sections",
1896
+ help="Seleccione uma ou mais áreas para completar antes de criar a tarefa.",
1897
+ )
1898
+ )
1899
+ st.caption("Seleccione as configurações que pretende completar.")
1900
+ else:
1901
+ selected_sections = set()
1902
+ st.success("Roteiro completo: todas as configurações estão disponíveis.")
1903
+
1904
+ all_channels = [channel for channel in read_json("channels.json", []) if isinstance(channel, dict)]
1905
+ selectable_channels = [channel for channel in all_channels if channel.get("active", True)]
1906
+ if not selectable_channels:
1907
+ st.warning("Cadastre pelo menos um canal antes de continuar.")
1908
+ return
1909
+ saved_channel_id = str(record.get("channel_id") or "")
1910
+ if saved_channel_id and not any(str(channel.get("id")) == saved_channel_id for channel in selectable_channels):
1911
+ saved_channel = next((channel for channel in all_channels if str(channel.get("id")) == saved_channel_id), None)
1912
+ if saved_channel:
1913
+ selectable_channels.insert(0, saved_channel)
1914
+ channel_index = next((index for index, channel in enumerate(selectable_channels) if str(channel.get("id")) == saved_channel_id), 0)
1915
+ selected_channel = st.selectbox(
1916
+ "Canal",
1917
+ selectable_channels,
1918
+ index=channel_index,
1919
+ format_func=lambda channel: str(channel.get("name") or "Canal sem nome"),
1920
+ key="new_video_resume_channel",
1921
+ )
1922
+
1923
+ settings_from_form = render_video_generation_settings(
1924
+ "new_video_resume",
1925
+ current_language=str(record.get("language") or "pt"),
1926
+ channel=selected_channel,
1927
+ sections=selected_sections,
1928
+ include_content=False,
1929
+ )
1930
+ merged_settings = {**persisted_settings, **settings_from_form}
1931
+ still_missing = missing_setting_sections(merged_settings)
1932
+ action_label = "Continuar criação" if still_missing else "Gerar apenas o vídeo"
1933
+ if still_missing:
1934
+ st.caption(f"Faltam: {', '.join(still_missing)}")
1935
+ elif not missing_sections:
1936
+ st.caption("Este roteiro será usado directamente, sem regenerar o conteúdo editorial guardado.")
1937
+ if st.button(action_label, type="primary", use_container_width=True, key="new_video_resume_submit", icon=":material/movie:"):
1938
+ if still_missing:
1939
+ st.error(f"Complete as configurações seleccionadas: {', '.join(still_missing)}.")
1940
+ elif not selected_channel.get("id"):
1941
+ st.error("Seleccione um canal para continuar.")
1942
+ else:
1943
+ tasks = _create_video_task_from_saved_script(record, selected_channel, merged_settings)
1944
+ st.success(f"Tarefa criada a partir de {record.get('title') or 'Roteiro sem título'}: {tasks[0].get('id') if tasks else '—'}.")
1945
+
1946
+
1751
1947
  def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "new_video"):
1752
1948
  st.title(page_title)
1753
- create_tab = render_localized_tabs(["Criar vídeo"])[0]
1949
+ tab_labels = ["Criar vídeo"] + (["Gerar de Rascunho"] if page_title == "Criação de Vídeos" else [])
1950
+ tabs = render_localized_tabs(tab_labels)
1951
+ create_tab = tabs[0]
1952
+ draft_tab = tabs[1] if len(tabs) > 1 else None
1754
1953
  with create_tab:
1755
1954
  all_channels = [c for c in read_json("channels.json", []) if isinstance(c, dict)]
1756
1955
  active_channels = [c for c in all_channels if c.get("active", True)]
@@ -2035,7 +2234,9 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2035
2234
  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.")
2036
2235
  st.session_state[f"{prefix}_creative_payload"] = payload
2037
2236
 
2237
+ st.session_state[f"{prefix}_generation_settings"] = dict(generation_settings)
2038
2238
  with st.form(f"{prefix}_form"):
2239
+
2039
2240
  quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel")
2040
2241
  language = generation_settings["script_language"]
2041
2242
  fmt = generation_settings["video_format"]
@@ -2115,6 +2316,10 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2115
2316
  tasks = create_tasks_for_batch(batch)
2116
2317
  st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s). Abra {ui_text('Backlog Vídeos', current_ui_language())} para acompanhar.")
2117
2318
 
2319
+ if draft_tab is not None:
2320
+ with draft_tab:
2321
+ render_video_from_draft()
2322
+
2118
2323
 
2119
2324
  def render_music_creation():
2120
2325
  """Expose the complete video-creation UI under the music-oriented navigation entry without changing the original page."""
@@ -2189,6 +2394,7 @@ def render_scripts():
2189
2394
  )
2190
2395
  language = script_settings["script_language"]
2191
2396
  structure_notes = script_settings["script_structure_notes"]
2397
+ st.session_state["pipeline_scripts_generation_settings"] = dict(script_settings)
2192
2398
  title = str(script_settings.get("video_subject") or "").strip()
2193
2399
  brief = str(script_settings.get("video_script") or "").strip() or title
2194
2400
  generate_col, clear_col = st.columns([1.4, 1])
@@ -3017,9 +3223,31 @@ def render_videos():
3017
3223
  st.rerun()
3018
3224
 
3019
3225
 
3226
+ def _thumbnail_editor_context(record: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
3227
+ """Resolve the persisted task channel/Blueprint without requiring either one to remain registered."""
3228
+ channel_id = str(record.get("channel_id") or "").strip()
3229
+ channels = read_json("channels.json", [])
3230
+ channel = next(
3231
+ (item for item in channels if isinstance(item, dict) and str(item.get("id") or "") == channel_id),
3232
+ None,
3233
+ ) if isinstance(channels, list) else None
3234
+ if not channel:
3235
+ channel = {
3236
+ "id": channel_id,
3237
+ "name": record.get("channel_name") or "Canal sem nome",
3238
+ "language": record.get("language") or "Português",
3239
+ "default_blueprint_id": record.get("blueprint_id") or "",
3240
+ "blueprint_id": record.get("blueprint_id") or "",
3241
+ }
3242
+ blueprint = blueprint_for_channel(channel)
3243
+ if not blueprint and record.get("blueprint_id"):
3244
+ blueprint = {"id": record.get("blueprint_id"), "name": record.get("blueprint_name") or record.get("blueprint_id")}
3245
+ return channel, blueprint
3246
+
3247
+
3020
3248
  def render_thumbnails():
3021
3249
  st.title("Thumbnails")
3022
- 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.")
3250
+ st.caption("Biblioteca de thumbnails associadas às tarefas da pipeline. Cada acção preserva a imagem anterior no histórico local.")
3023
3251
  records = list_thumbnail_tasks()
3024
3252
  if not records:
3025
3253
  st.info("Ainda não existem tarefas com thumbnail gerada ou prompt de imagem disponível.")
@@ -3027,8 +3255,9 @@ def render_thumbnails():
3027
3255
 
3028
3256
  settings = read_json("settings.json", {})
3029
3257
  for record in records:
3258
+ task_id = record["task_id"]
3030
3259
  with st.container(border=True):
3031
- image_col, details_col, action_col = st.columns([1.35, 2.65, 1.25])
3260
+ image_col, details_col, action_col = st.columns([1.25, 2.35, 1.7])
3032
3261
  with image_col:
3033
3262
  image_path = record.get("image_path")
3034
3263
  if image_path and image_path.is_file():
@@ -3038,32 +3267,157 @@ def render_thumbnails():
3038
3267
  st.caption("Imagem ainda não gerada")
3039
3268
  with details_col:
3040
3269
  st.write(f"**{record['title']}**")
3041
- st.caption(f"Canal: {record['channel_name']} · Tarefa: {record['task_id']}")
3270
+ st.caption(f"Canal: {record['channel_name']} · Tarefa: {task_id}")
3042
3271
  st.caption(f"Estado: {record['status']} · Variante: {record['variant_index'] + 1}")
3043
3272
  if record["prompt"]:
3044
3273
  with st.expander("Ver prompt da thumbnail", expanded=False):
3045
3274
  st.code(record["prompt"], language="text")
3046
3275
  else:
3047
- st.warning("Esta tarefa não tem prompt de imagem. Não é possível refazer a thumbnail.")
3276
+ st.warning("A thumbnail não tem um prompt de imagem para gerar.")
3277
+
3048
3278
  with action_col:
3049
3279
  if st.button(
3050
- "Refazer thumbnail",
3051
- key=f"regenerate_thumbnail_{record['task_id']}",
3280
+ "Refazer Prompt Thumb",
3281
+ key=f"regenerate_thumbnail_{task_id}",
3052
3282
  icon=":material/refresh:",
3053
3283
  use_container_width=True,
3284
+ disabled=not bool(record["title"] or record["topic"]),
3285
+ ):
3286
+ try:
3287
+ with st.spinner("A refazer apenas o prompt da thumbnail…"):
3288
+ channel, blueprint = _thumbnail_editor_context(record)
3289
+ _task, prompt_variant = regenerate_thumbnail_prompt(
3290
+ task_id,
3291
+ settings,
3292
+ channel,
3293
+ blueprint=blueprint,
3294
+ language=str(record.get("language") or current_ui_language()),
3295
+ )
3296
+ record_notification(
3297
+ "thumbnail_generation_completed",
3298
+ "Prompt da thumbnail refeito",
3299
+ "Prompt da thumbnail actualizado; a imagem existente foi preservada.",
3300
+ metadata={
3301
+ "task_id": task_id,
3302
+ "channel_name": record["channel_name"],
3303
+ "prompt_regenerated": True,
3304
+ "prompt_only": True,
3305
+ "image_path": str(record.get("image_path") or ""),
3306
+ },
3307
+ dedupe_key=f"thumbnail:prompt-only:{task_id}:{prompt_variant.get('image_prompt', '')}",
3308
+ )
3309
+ st.success("Prompt da thumbnail actualizado; a imagem existente foi preservada.")
3310
+ st.rerun()
3311
+ except (CreativeGenerationError, ThumbnailGenerationError) as exc:
3312
+ st.error(str(exc))
3313
+
3314
+ if st.button(
3315
+ "Gerar Imagem",
3316
+ key=f"generate_thumbnail_image_{task_id}",
3317
+ icon=":material/image:",
3318
+ use_container_width=True,
3054
3319
  disabled=not bool(record["prompt"]),
3055
3320
  ):
3056
3321
  try:
3057
- with st.spinner("A refazer a thumbnail…"):
3058
- _task, image_path = regenerate_thumbnail(record["task_id"], settings)
3322
+ with st.spinner("A gerar a imagem com Nano Banana…"):
3323
+ _task, generated_path = generate_thumbnail_for_task(task_id, settings)
3324
+ record_notification(
3325
+ "thumbnail_generation_completed",
3326
+ f"Thumbnail gerada: {record['title']}",
3327
+ "Thumbnail gerada com sucesso.",
3328
+ metadata={"task_id": task_id, "channel_name": record["channel_name"], "image_path": str(generated_path)},
3329
+ dedupe_key=f"thumbnail:generated:{task_id}:{generated_path}",
3330
+ )
3331
+ st.success("Thumbnail gerada com sucesso.")
3332
+ st.rerun()
3333
+ except ThumbnailGenerationError as exc:
3334
+ st.error(str(exc))
3335
+
3336
+ if st.button(
3337
+ "Refazer Prompt e Gerar Imagem",
3338
+ key=f"regenerate_thumbnail_prompt_{task_id}",
3339
+ icon=":material/auto_awesome:",
3340
+ use_container_width=True,
3341
+ disabled=not bool(record["title"] or record["topic"]),
3342
+ ):
3343
+ try:
3344
+ with st.spinner("A refazer o prompt e a imagem…"):
3345
+ channel, blueprint = _thumbnail_editor_context(record)
3346
+ variant = generate_thumbnail_prompt(
3347
+ settings,
3348
+ channel,
3349
+ record["title"] or record["topic"],
3350
+ current_prompt=record["prompt"],
3351
+ blueprint=blueprint,
3352
+ language=str(record.get("language") or current_ui_language()),
3353
+ )
3354
+ _task, generated_path = regenerate_thumbnail_prompt_and_image(task_id, settings, variant)
3355
+ record_notification(
3356
+ "thumbnail_generation_completed",
3357
+ f"Thumbnail renovada: {record['title']}",
3358
+ "Prompt da thumbnail e imagem actualizados.",
3359
+ metadata={"task_id": task_id, "channel_name": record["channel_name"], "image_path": str(generated_path), "prompt_regenerated": True},
3360
+ dedupe_key=f"thumbnail:prompt-regenerated:{task_id}:{generated_path}",
3361
+ )
3362
+ st.success("Prompt da thumbnail e imagem actualizados.")
3363
+ st.rerun()
3364
+ except (CreativeGenerationError, ThumbnailGenerationError) as exc:
3365
+ st.error(str(exc))
3366
+
3367
+ if st.button(
3368
+ "Refazer Lettering",
3369
+ key=f"regenerate_thumbnail_lettering_{task_id}",
3370
+ icon=":material/title:",
3371
+ use_container_width=True,
3372
+ disabled=not bool(record.get("image_path") and record["image_path"].is_file()),
3373
+ ):
3374
+ try:
3375
+ with st.spinner("A refazer apenas o lettering…"):
3376
+ _task, generated_path = regenerate_thumbnail_lettering(
3377
+ task_id,
3378
+ settings,
3379
+ lettering_prompt=record.get("lettering_prompt") or "",
3380
+ )
3381
+ record_notification(
3382
+ "thumbnail_generation_completed",
3383
+ f"Lettering refeito: {record['title']}",
3384
+ "Lettering refeito; a imagem original foi usada como base.",
3385
+ metadata={"task_id": task_id, "channel_name": record["channel_name"], "image_path": str(generated_path), "lettering_only": True},
3386
+ dedupe_key=f"thumbnail:lettering:{task_id}:{generated_path}",
3387
+ )
3388
+ st.success("Lettering refeito; a imagem original foi usada como base.")
3389
+ st.rerun()
3390
+ except ThumbnailGenerationError as exc:
3391
+ st.error(str(exc))
3392
+
3393
+ uploaded = st.file_uploader(
3394
+ "Upload Image",
3395
+ type=["png", "jpg", "jpeg", "webp"],
3396
+ key=f"thumbnail_upload_{task_id}",
3397
+ help="Suba uma imagem para a associar a esta tarefa e à pipeline.",
3398
+ )
3399
+ if uploaded is not None:
3400
+ uploaded_bytes = uploaded.getvalue()
3401
+ uploaded_digest = hashlib.sha256(uploaded_bytes).hexdigest()
3402
+ digest_key = f"thumbnail_upload_digest_{task_id}"
3403
+ if st.session_state.get(digest_key) != uploaded_digest:
3404
+ try:
3405
+ with st.spinner("A guardar a imagem carregada…"):
3406
+ _task, uploaded_path = upload_thumbnail_image(
3407
+ task_id,
3408
+ uploaded_bytes,
3409
+ uploaded.name,
3410
+ uploaded.type,
3411
+ )
3412
+ st.session_state[digest_key] = uploaded_digest
3059
3413
  record_notification(
3060
3414
  "thumbnail_generation_completed",
3061
- f"Thumbnail refeita: {record['title']}",
3062
- "A thumbnail da tarefa foi regenerada com sucesso.",
3063
- metadata={"task_id": record["task_id"], "channel_name": record["channel_name"], "image_path": str(image_path)},
3064
- dedupe_key=f"thumbnail:regenerated:{record['task_id']}:{image_path}",
3415
+ f"Thumbnail carregada: {record['title']}",
3416
+ "Imagem carregada e vinculada à tarefa.",
3417
+ metadata={"task_id": task_id, "channel_name": record["channel_name"], "image_path": str(uploaded_path), "source": "upload"},
3418
+ dedupe_key=f"thumbnail:uploaded:{task_id}:{uploaded_digest}",
3065
3419
  )
3066
- st.success("Thumbnail refeita com sucesso.")
3420
+ st.success("Imagem carregada e vinculada à tarefa.")
3067
3421
  st.rerun()
3068
3422
  except ThumbnailGenerationError as exc:
3069
3423
  st.error(str(exc))