@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 +494 -140
- package/hermes_ui/creative_generation.py +54 -0
- package/hermes_ui/draft_video.py +127 -0
- package/hermes_ui/languages.py +117 -2
- package/hermes_ui/thumbnail_generation.py +28 -1
- package/hermes_ui/thumbnails.py +276 -37
- package/package.json +1 -1
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
|
|
50
|
-
|
|
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
|
|
728
|
+
"""Render shared settings, optionally limiting the visible video sections."""
|
|
717
729
|
settings: dict[str, Any] = {}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
|
781
|
-
st.
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
if
|
|
813
|
-
st.
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
settings["
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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: {
|
|
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("
|
|
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
|
|
3051
|
-
key=f"regenerate_thumbnail_{
|
|
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
|
|
3058
|
-
_task,
|
|
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
|
|
3062
|
-
"
|
|
3063
|
-
metadata={"task_id":
|
|
3064
|
-
dedupe_key=f"thumbnail:
|
|
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("
|
|
3420
|
+
st.success("Imagem carregada e vinculada à tarefa.")
|
|
3067
3421
|
st.rerun()
|
|
3068
3422
|
except ThumbnailGenerationError as exc:
|
|
3069
3423
|
st.error(str(exc))
|