@danhachuel/thunderbolt 0.7.30 → 0.7.32
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 +196 -18
- package/hermes_ui/cuts.py +4 -0
- package/hermes_ui/facebook_automation.py +220 -0
- package/hermes_ui/python_editor.py +2 -0
- package/package.json +1 -1
package/app/main.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import sys
|
|
3
3
|
import io
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
4
6
|
|
|
5
7
|
os.environ["PYTHONIOENCODING"] = "utf-8"
|
|
6
8
|
os.environ["PYTHONUTF8"] = "1"
|
|
@@ -139,6 +141,7 @@ from hermes_ui.growth_tiktok import render_growth_tiktok
|
|
|
139
141
|
from hermes_ui.growth_instagram import render_growth_instagram
|
|
140
142
|
from hermes_ui.growth_facebook_pages import render_growth_facebook_pages
|
|
141
143
|
from hermes_ui.growth_bilibili import render_growth_bilibili
|
|
144
|
+
from hermes_ui.facebook_automation import caption_images, collect_images, create_post, generate_article, generate_theme, list_posts, publish_to_facebook, save_post
|
|
142
145
|
from hermes_ui.canva_auth import authorization_url, create_pkce_pair, create_state, exchange_code
|
|
143
146
|
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
|
|
144
147
|
from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
|
|
@@ -632,6 +635,32 @@ def _library_card_key(kind: str, path: Path) -> str:
|
|
|
632
635
|
return hashlib.sha1(f"{kind}:{path.resolve()}".encode("utf-8")).hexdigest()[:12]
|
|
633
636
|
|
|
634
637
|
|
|
638
|
+
def _video_thumbnail(path: Path) -> bytes | None:
|
|
639
|
+
"""Create a small cached preview frame for a local video card."""
|
|
640
|
+
if not path.is_file():
|
|
641
|
+
return None
|
|
642
|
+
thumbnail_dir = STORAGE / "ui_cache" / "video_thumbnails"
|
|
643
|
+
thumbnail_dir.mkdir(parents=True, exist_ok=True)
|
|
644
|
+
target = thumbnail_dir / f"{hashlib.sha1(str(path.resolve()).encode('utf-8')).hexdigest()}.jpg"
|
|
645
|
+
try:
|
|
646
|
+
if target.is_file() and target.stat().st_mtime >= path.stat().st_mtime:
|
|
647
|
+
return target.read_bytes()
|
|
648
|
+
ffmpeg = shutil.which("ffmpeg")
|
|
649
|
+
if not ffmpeg:
|
|
650
|
+
return None
|
|
651
|
+
completed = subprocess.run(
|
|
652
|
+
[ffmpeg, "-y", "-hide_banner", "-loglevel", "error", "-ss", "00:00:01", "-i", str(path), "-frames:v", "1", "-vf", "scale=640:-2", str(target)],
|
|
653
|
+
capture_output=True,
|
|
654
|
+
check=False,
|
|
655
|
+
timeout=20,
|
|
656
|
+
)
|
|
657
|
+
if completed.returncode == 0 and target.is_file():
|
|
658
|
+
return target.read_bytes()
|
|
659
|
+
except (OSError, subprocess.SubprocessError):
|
|
660
|
+
return None
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
|
|
635
664
|
def _render_library_name_editor(kind: str, path: Path, current_name: str) -> str:
|
|
636
665
|
"""Render the inline name editor while keeping the physical filename unchanged."""
|
|
637
666
|
edit_key = f"rename_{kind}_{_library_card_key(kind, path)}"
|
|
@@ -4832,12 +4861,7 @@ def render_media_download():
|
|
|
4832
4861
|
|
|
4833
4862
|
def render_cuts():
|
|
4834
4863
|
st.title("Cortes")
|
|
4835
|
-
st.caption("Crie clips verticais, quadrados ou horizontais a partir de vídeos longos,
|
|
4836
|
-
|
|
4837
|
-
st.markdown(
|
|
4838
|
-
"<div class='tb-cuts-hero'><div class='tb-cuts-kicker'>01 · CLIP GENERATOR</div><h2>Create Viral Shorts</h2><p>Escolha um vídeo longo, defina o formato e gere clips locais sem sobrescrever a fonte.</p></div>",
|
|
4839
|
-
unsafe_allow_html=True,
|
|
4840
|
-
)
|
|
4864
|
+
st.caption("Crie clips verticais, quadrados ou horizontais a partir de vídeos longos, sem sobrescrever a fonte.")
|
|
4841
4865
|
|
|
4842
4866
|
source_path = None
|
|
4843
4867
|
source_tab, url_tab, generated_tab, folder_tab = render_localized_tabs(["Upload ficheiro", "URL de vídeo", "Vídeos gerados", "Pasta local"])
|
|
@@ -4869,14 +4893,27 @@ def render_cuts():
|
|
|
4869
4893
|
with generated_tab:
|
|
4870
4894
|
generated_paths = list_cut_generated_videos(read_json("tasks.json", []))
|
|
4871
4895
|
if not generated_paths:
|
|
4872
|
-
st.info("Ainda não existem vídeos
|
|
4896
|
+
st.info("Ainda não existem vídeos prontos na pipeline.")
|
|
4873
4897
|
else:
|
|
4874
|
-
|
|
4875
|
-
|
|
4876
|
-
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
|
|
4898
|
+
st.caption(f"{len(generated_paths)} vídeo(s) pronto(s) disponível(is).")
|
|
4899
|
+
for index, generated_path in enumerate(generated_paths):
|
|
4900
|
+
with st.expander(generated_path.stem, expanded=False):
|
|
4901
|
+
card_cols = st.columns([1.35, 2.4, 1.1])
|
|
4902
|
+
with card_cols[0]:
|
|
4903
|
+
thumbnail = _video_thumbnail(generated_path)
|
|
4904
|
+
if thumbnail:
|
|
4905
|
+
st.image(thumbnail, width="stretch")
|
|
4906
|
+
else:
|
|
4907
|
+
st.video(str(generated_path))
|
|
4908
|
+
with card_cols[1]:
|
|
4909
|
+
st.markdown(f"**{generated_path.stem}**")
|
|
4910
|
+
st.caption(f"Vídeo pronto · {generated_path.suffix.lstrip('.').upper()}")
|
|
4911
|
+
st.caption(f"{generated_path.stat().st_size / (1024 * 1024):.2f} MB")
|
|
4912
|
+
with card_cols[2]:
|
|
4913
|
+
if st.button("Usar vídeo", key=f"cuts_use_generated_{index}_{_library_card_key('cuts', generated_path)}", type="primary", width="stretch"):
|
|
4914
|
+
st.session_state["cuts_source_path"] = str(generated_path)
|
|
4915
|
+
st.session_state["cuts_source_label"] = f"Pipeline · {generated_path.stem}"
|
|
4916
|
+
st.rerun()
|
|
4880
4917
|
with folder_tab:
|
|
4881
4918
|
folder_value = st.text_input("Pasta de vídeos", value=str(STORAGE / "videos"), key="cuts_video_folder")
|
|
4882
4919
|
folder_paths = list_cut_video_files(folder_value)
|
|
@@ -5027,11 +5064,28 @@ def render_python_editor():
|
|
|
5027
5064
|
tasks = read_json("tasks.json", [])
|
|
5028
5065
|
generated_paths = list_generated_videos(tasks)
|
|
5029
5066
|
if not generated_paths:
|
|
5030
|
-
st.info("Ainda não existem vídeos
|
|
5067
|
+
st.info("Ainda não existem vídeos prontos nos artefactos da pipeline.")
|
|
5031
5068
|
else:
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5069
|
+
st.caption("Seleccione um vídeo pronto na pipeline.")
|
|
5070
|
+
for index, generated_path in enumerate(generated_paths):
|
|
5071
|
+
with st.expander(generated_path.stem, expanded=False):
|
|
5072
|
+
card_cols = st.columns([1.35, 2.4, 1.1])
|
|
5073
|
+
with card_cols[0]:
|
|
5074
|
+
thumbnail = _video_thumbnail(generated_path)
|
|
5075
|
+
if thumbnail:
|
|
5076
|
+
st.image(thumbnail, width="stretch")
|
|
5077
|
+
else:
|
|
5078
|
+
st.video(str(generated_path))
|
|
5079
|
+
with card_cols[1]:
|
|
5080
|
+
st.markdown(f"**{generated_path.stem}**")
|
|
5081
|
+
st.caption(f"Vídeo pronto · {generated_path.suffix.lstrip('.').upper()}")
|
|
5082
|
+
with card_cols[2]:
|
|
5083
|
+
if st.button("Editar vídeo", key=f"python_use_generated_{index}_{_library_card_key('python', generated_path)}", type="primary", width="stretch"):
|
|
5084
|
+
st.session_state["python_editor_source_path"] = str(generated_path)
|
|
5085
|
+
st.rerun()
|
|
5086
|
+
stored_generated = Path(str(st.session_state.get("python_editor_source_path") or ""))
|
|
5087
|
+
if stored_generated.is_file() and stored_generated in generated_paths:
|
|
5088
|
+
source_path = stored_generated
|
|
5035
5089
|
elif source_mode == "Pasta local":
|
|
5036
5090
|
default_folder = str(STORAGE / "videos")
|
|
5037
5091
|
folder_value = st.text_input("Pasta de vídeos", value=st.session_state.get("python_editor_folder", default_folder), key="python_editor_folder")
|
|
@@ -6283,6 +6337,130 @@ def _render_youtube_automation_cards():
|
|
|
6283
6337
|
st.rerun()
|
|
6284
6338
|
|
|
6285
6339
|
|
|
6340
|
+
def _facebook_pages_for_automation() -> list[dict[str, Any]]:
|
|
6341
|
+
return [channel for channel in read_json("channels.json", []) if isinstance(channel, dict) and classify_channel_platform(channel) == "facebook"]
|
|
6342
|
+
|
|
6343
|
+
|
|
6344
|
+
@st.fragment(run_every=5.0)
|
|
6345
|
+
def _render_facebook_automation_cards() -> None:
|
|
6346
|
+
st.divider()
|
|
6347
|
+
st.subheader("Posts Facebook em produção")
|
|
6348
|
+
posts = list_posts()
|
|
6349
|
+
if not posts:
|
|
6350
|
+
st.info("Ainda não existem posts Facebook em produção.")
|
|
6351
|
+
return
|
|
6352
|
+
for post in posts:
|
|
6353
|
+
post_id = str(post.get("id") or "")
|
|
6354
|
+
with st.container(border=True):
|
|
6355
|
+
cols = st.columns([2.5, 1.35, 1.15, 2.0], gap="small")
|
|
6356
|
+
with cols[0]:
|
|
6357
|
+
st.write(f"**{post.get('title') or post.get('theme') or 'Post sem tema'}**")
|
|
6358
|
+
st.caption(f"{post.get('page_name') or 'Facebook Page'} · {post_id}")
|
|
6359
|
+
st.caption(f"Etapa: {post.get('status', 'tema_pendente')}")
|
|
6360
|
+
if post.get("article_text"):
|
|
6361
|
+
with st.expander("Ver artigo", expanded=False):
|
|
6362
|
+
st.text_area("Artigo", value=str(post["article_text"]), height=220, key=f"facebook_article_{post_id}", disabled=True)
|
|
6363
|
+
image_records = post.get("images") if isinstance(post.get("images"), list) else []
|
|
6364
|
+
if image_records:
|
|
6365
|
+
image_cols = st.columns(min(5, len(image_records)))
|
|
6366
|
+
for image_col, image in zip(image_cols, image_records):
|
|
6367
|
+
image_path = Path(str(image.get("captioned_path") or image.get("path") or ""))
|
|
6368
|
+
with image_col:
|
|
6369
|
+
if image_path.is_file():
|
|
6370
|
+
st.image(str(image_path), use_container_width=True)
|
|
6371
|
+
st.caption(str(image.get("status") or "pendente"))
|
|
6372
|
+
with cols[1]:
|
|
6373
|
+
st.caption("Estado")
|
|
6374
|
+
st.write(str(post.get("status") or "tema_pendente").replace("_", " ").capitalize())
|
|
6375
|
+
st.progress({"tema_pendente": 0, "artigo_pendente": 20, "imagens_pendentes": 45, "legendas_pendentes": 70, "pronto_upload": 90, "publicado": 100}.get(str(post.get("status")), 0), text=f"{len(post.get('images') or [])}/5 imagens")
|
|
6376
|
+
with cols[2]:
|
|
6377
|
+
st.caption("Imagens")
|
|
6378
|
+
st.write(f"{len(post.get('images') or [])} previstas")
|
|
6379
|
+
st.caption("Google Imagens ou IA")
|
|
6380
|
+
with cols[3]:
|
|
6381
|
+
settings = read_json("settings.json", {})
|
|
6382
|
+
pages = _facebook_pages_for_automation()
|
|
6383
|
+
page = next((item for item in pages if str(item.get("id")) == str(post.get("page_id"))), {})
|
|
6384
|
+
if st.button("Gerar tema", key=f"facebook_generate_theme_{post_id}", disabled=str(post.get("status")) not in {"tema_pendente", "erro"}, width="stretch"):
|
|
6385
|
+
try:
|
|
6386
|
+
generate_theme(settings, post, page)
|
|
6387
|
+
st.success("Tema criado pelo LLM.")
|
|
6388
|
+
st.rerun()
|
|
6389
|
+
except Exception as exc:
|
|
6390
|
+
st.error(str(exc))
|
|
6391
|
+
if st.button("Criar artigo", key=f"facebook_generate_article_{post_id}", disabled=str(post.get("status")) != "artigo_pendente", width="stretch"):
|
|
6392
|
+
try:
|
|
6393
|
+
generate_article(settings, post, page)
|
|
6394
|
+
st.success("Artigo e prompts de imagens criados pelo LLM.")
|
|
6395
|
+
st.rerun()
|
|
6396
|
+
except Exception as exc:
|
|
6397
|
+
st.error(str(exc))
|
|
6398
|
+
source = st.selectbox("Fonte das imagens", ["Google Imagens", "Gerar com IA"], key=f"facebook_image_source_{post_id}")
|
|
6399
|
+
if st.button("Buscar/Gerar imagens", key=f"facebook_collect_images_{post_id}", disabled=str(post.get("status")) != "imagens_pendentes", width="stretch"):
|
|
6400
|
+
try:
|
|
6401
|
+
collect_images(settings, post, source="google" if source == "Google Imagens" else "ai")
|
|
6402
|
+
st.success("Imagens processadas.")
|
|
6403
|
+
st.rerun()
|
|
6404
|
+
except Exception as exc:
|
|
6405
|
+
st.error(str(exc))
|
|
6406
|
+
if st.button("Legendar imagens", key=f"facebook_caption_images_{post_id}", disabled=str(post.get("status")) != "legendas_pendentes", width="stretch"):
|
|
6407
|
+
try:
|
|
6408
|
+
caption_images(post)
|
|
6409
|
+
st.success("Textos aplicados nas imagens com Pillow.")
|
|
6410
|
+
st.rerun()
|
|
6411
|
+
except Exception as exc:
|
|
6412
|
+
st.error(str(exc))
|
|
6413
|
+
if st.button("Upload para Facebook", key=f"facebook_publish_{post_id}", type="primary", disabled=str(post.get("status")) != "pronto_upload", width="stretch"):
|
|
6414
|
+
try:
|
|
6415
|
+
publish_to_facebook(post, page)
|
|
6416
|
+
st.success("Post publicado na Facebook Page.")
|
|
6417
|
+
st.rerun()
|
|
6418
|
+
except Exception as exc:
|
|
6419
|
+
st.error(str(exc))
|
|
6420
|
+
|
|
6421
|
+
|
|
6422
|
+
def render_facebook_automation() -> None:
|
|
6423
|
+
st.title("Automação Facebook")
|
|
6424
|
+
st.caption("Fluxo local de posts: Ideia/Tema → LLM → Artigo → Google Imagens ou IA → textos e legendas → Upload pela Meta Graph API.")
|
|
6425
|
+
st.info("Esta página não usa planilhas nem Google Drive. Os artigos, imagens, legendas e estados ficam no armazenamento local do Thunderbolt.")
|
|
6426
|
+
pages = _facebook_pages_for_automation()
|
|
6427
|
+
settings = read_json("settings.json", {})
|
|
6428
|
+
if not pages:
|
|
6429
|
+
st.warning("Cadastre primeiro uma página em Canais e Perfis de Vídeos > Facebook Pages.")
|
|
6430
|
+
return
|
|
6431
|
+
with st.expander("Configurar API Facebook da página", expanded=False):
|
|
6432
|
+
for page in pages:
|
|
6433
|
+
page_id = str(page.get("id") or "")
|
|
6434
|
+
with st.form(f"facebook_page_api_{page_id}"):
|
|
6435
|
+
st.write(f"**{page.get('name') or 'Facebook Page'}**")
|
|
6436
|
+
meta_page_id = st.text_input("Page ID da Meta", value=str(page.get("page_id") or page.get("facebook_page_id") or ""), key=f"facebook_meta_page_id_{page_id}")
|
|
6437
|
+
access_token = st.text_input("Access Token da Page", value=str(page.get("access_token") or page.get("facebook_access_token") or ""), type="password", key=f"facebook_meta_token_{page_id}")
|
|
6438
|
+
if st.form_submit_button("Guardar credenciais Facebook", type="primary"):
|
|
6439
|
+
update_channel(page_id, {"page_id": meta_page_id.strip(), "facebook_page_id": meta_page_id.strip(), "access_token": access_token.strip(), "facebook_access_token": access_token.strip()})
|
|
6440
|
+
st.success("Credenciais da página guardadas localmente.")
|
|
6441
|
+
st.rerun()
|
|
6442
|
+
with st.form("facebook_automation_new_post"):
|
|
6443
|
+
page_options = [str(page.get("id")) for page in pages]
|
|
6444
|
+
page_labels = {str(page.get("id")): str(page.get("name") or "Facebook Page") for page in pages}
|
|
6445
|
+
selected_page = st.selectbox("Facebook Page", page_options, format_func=lambda value: page_labels.get(value, value))
|
|
6446
|
+
form_cols = st.columns([2.2, 1, 1.3])
|
|
6447
|
+
with form_cols[0]:
|
|
6448
|
+
theme = st.text_input("Tema/ideia opcional", placeholder="Deixe vazio para o LLM sugerir um tema")
|
|
6449
|
+
with form_cols[1]:
|
|
6450
|
+
image_count = st.number_input("Imagens", min_value=1, max_value=5, value=3, step=1)
|
|
6451
|
+
with form_cols[2]:
|
|
6452
|
+
start_stage = st.selectbox("Iniciar por", ["Ideia / Tema", "Tema já definido"])
|
|
6453
|
+
create_clicked = st.form_submit_button("Criar post na fila", type="primary", width="stretch")
|
|
6454
|
+
if create_clicked:
|
|
6455
|
+
page = next(page for page in pages if str(page.get("id")) == selected_page)
|
|
6456
|
+
post = create_post(page, image_count=int(image_count), theme=theme)
|
|
6457
|
+
if start_stage == "Tema já definido" and theme.strip():
|
|
6458
|
+
save_post({**post, "status": "artigo_pendente"})
|
|
6459
|
+
st.success("Post criado na fila local. Execute cada etapa pelos botões do card.")
|
|
6460
|
+
st.rerun()
|
|
6461
|
+
_render_facebook_automation_cards()
|
|
6462
|
+
|
|
6463
|
+
|
|
6286
6464
|
def render_automation():
|
|
6287
6465
|
st.title("Automação Youtube")
|
|
6288
6466
|
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.")
|
|
@@ -9847,7 +10025,7 @@ def main():
|
|
|
9847
10025
|
"Facebook Pages": render_facebook_pages,
|
|
9848
10026
|
"Automação Youtube": render_automation,
|
|
9849
10027
|
"Automação Tiktok": render_tiktok_automation,
|
|
9850
|
-
"Automação Facebook":
|
|
10028
|
+
"Automação Facebook": render_facebook_automation,
|
|
9851
10029
|
"Automação Musicas": lambda: None,
|
|
9852
10030
|
"Automação UGC": lambda: None,
|
|
9853
10031
|
"Automação Influencer Content": lambda: None,
|
package/hermes_ui/cuts.py
CHANGED
|
@@ -121,6 +121,10 @@ def list_video_files(folder: str | Path) -> list[Path]:
|
|
|
121
121
|
def list_generated_videos(tasks: Iterable[dict[str, Any]]) -> list[Path]:
|
|
122
122
|
paths: dict[str, Path] = {}
|
|
123
123
|
for task in tasks:
|
|
124
|
+
# Um caminho pode existir enquanto a tarefa ainda está a ser processada;
|
|
125
|
+
# só artefactos explicitamente prontos devem entrar nesta biblioteca.
|
|
126
|
+
if "video_ready" in task and not bool(task.get("video_ready")):
|
|
127
|
+
continue
|
|
124
128
|
artifacts = task.get("artifacts") or {}
|
|
125
129
|
value = artifacts.get("video")
|
|
126
130
|
if isinstance(value, str) and value.strip():
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import textwrap
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Mapping
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
13
|
+
|
|
14
|
+
from .creative_generation import CreativeGenerationError, _chat_json
|
|
15
|
+
from .media_generation import generate_image_for_card
|
|
16
|
+
from .storage import STORAGE, read_json, write_json
|
|
17
|
+
|
|
18
|
+
POSTS_FILE = "facebook_automation_posts.json"
|
|
19
|
+
POSTS_DIR = STORAGE / "facebook_automation"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _now() -> str:
|
|
23
|
+
return datetime.now(timezone.utc).isoformat()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _posts() -> list[dict[str, Any]]:
|
|
27
|
+
value = read_json(POSTS_FILE, [])
|
|
28
|
+
return [dict(item) for item in value if isinstance(item, Mapping)] if isinstance(value, list) else []
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def list_posts() -> list[dict[str, Any]]:
|
|
32
|
+
return sorted(_posts(), key=lambda item: str(item.get("created_at") or ""), reverse=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def save_post(post: Mapping[str, Any]) -> dict[str, Any]:
|
|
36
|
+
value = dict(post)
|
|
37
|
+
value.setdefault("id", f"fbpost_{uuid.uuid4().hex[:12]}")
|
|
38
|
+
value.setdefault("created_at", _now())
|
|
39
|
+
entries = _posts()
|
|
40
|
+
for index, existing in enumerate(entries):
|
|
41
|
+
if str(existing.get("id")) == str(value["id"]):
|
|
42
|
+
entries[index] = value
|
|
43
|
+
break
|
|
44
|
+
else:
|
|
45
|
+
entries.append(value)
|
|
46
|
+
write_json(POSTS_FILE, entries)
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def create_post(page: Mapping[str, Any], *, image_count: int = 3, theme: str = "") -> dict[str, Any]:
|
|
51
|
+
post_id = f"fbpost_{uuid.uuid4().hex[:12]}"
|
|
52
|
+
folder = POSTS_DIR / post_id / "images"
|
|
53
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
return save_post({
|
|
55
|
+
"id": post_id,
|
|
56
|
+
"page_id": str(page.get("id") or ""),
|
|
57
|
+
"page_name": str(page.get("name") or "Facebook Page"),
|
|
58
|
+
"page_url": str(page.get("url") or ""),
|
|
59
|
+
"theme": theme.strip(),
|
|
60
|
+
"tone": "Motivacional/Superação",
|
|
61
|
+
"image_count": max(1, min(5, int(image_count))),
|
|
62
|
+
"status": "tema_pendente",
|
|
63
|
+
"created_at": _now(),
|
|
64
|
+
"folder": str(folder),
|
|
65
|
+
"images": [],
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def generate_theme(settings: dict[str, Any], post: Mapping[str, Any], page: Mapping[str, Any], context: str = "") -> dict[str, Any]:
|
|
70
|
+
system = (
|
|
71
|
+
"És um curador de temas para posts virais de storytelling em português para uma Facebook Page. "
|
|
72
|
+
"Escolhe um tema específico, novo e visualmente forte, com contexto, obstáculo e reviravolta. "
|
|
73
|
+
"Não inventes factos sobre pessoas reais; quando não houver pessoa indicada, cria uma história genérica plausível. "
|
|
74
|
+
"Responde apenas JSON válido com as chaves tema e tom."
|
|
75
|
+
)
|
|
76
|
+
result = _chat_json(settings, system, json.dumps({
|
|
77
|
+
"page": {"name": page.get("name"), "description": page.get("description"), "niche": page.get("niche"), "country": page.get("country")},
|
|
78
|
+
"context": context or post.get("theme") or "",
|
|
79
|
+
"tone_options": ["Motivacional/Superação", "Curiosidade Histórica", "Biografia de Empresário"],
|
|
80
|
+
}, ensure_ascii=False))
|
|
81
|
+
theme = str(result.get("tema") or result.get("theme") or "").strip()
|
|
82
|
+
tone = str(result.get("tom") or result.get("tone") or "Motivacional/Superação").strip()
|
|
83
|
+
if not theme:
|
|
84
|
+
raise CreativeGenerationError("O LLM não devolveu um tema válido para a página Facebook.")
|
|
85
|
+
return save_post({**post, "theme": theme, "tone": tone, "status": "artigo_pendente"})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def generate_article(settings: dict[str, Any], post: Mapping[str, Any], page: Mapping[str, Any]) -> dict[str, Any]:
|
|
89
|
+
image_count = max(1, min(5, int(post.get("image_count") or 3)))
|
|
90
|
+
system = (
|
|
91
|
+
"És um roteirista de posts virais de storytelling para Facebook, em português. "
|
|
92
|
+
"Gera um artigo completo com aproximadamente 650 a 900 palavras, semelhante ao formato de storytelling do exemplo: "
|
|
93
|
+
"frases curtas, uma ideia por linha, quebras frequentes, contexto de ano/lugar, factos e números concretos, "
|
|
94
|
+
"obstáculos, reviravoltas e uma frase final de efeito. Não inventes factos sobre pessoas reais. "
|
|
95
|
+
f"Depois do artigo, gera exatamente {image_count} cards em ordem cronológica. Cada card deve conter "
|
|
96
|
+
"index, search_query e overlay_text com no máximo 25 palavras. Responde apenas JSON válido com title, article_text e images."
|
|
97
|
+
)
|
|
98
|
+
result = _chat_json(settings, system, json.dumps({
|
|
99
|
+
"page": {"name": page.get("name"), "description": page.get("description"), "niche": page.get("niche"), "country": page.get("country")},
|
|
100
|
+
"theme": post.get("theme"), "tone": post.get("tone"), "image_count": image_count,
|
|
101
|
+
"requirements": {"approximate_words": "650-900", "language": "pt-BR", "format": "one idea per line"},
|
|
102
|
+
}, ensure_ascii=False))
|
|
103
|
+
article = str(result.get("article_text") or result.get("article") or "").strip()
|
|
104
|
+
title = str(result.get("title") or post.get("theme") or "Post Facebook").strip()
|
|
105
|
+
images = result.get("images") if isinstance(result.get("images"), list) else []
|
|
106
|
+
normalized_images = []
|
|
107
|
+
for index, item in enumerate(images[:image_count], start=1):
|
|
108
|
+
if isinstance(item, Mapping):
|
|
109
|
+
normalized_images.append({"index": index, "search_query": str(item.get("search_query") or "").strip(), "overlay_text": str(item.get("overlay_text") or "").strip()})
|
|
110
|
+
if not article or not normalized_images:
|
|
111
|
+
raise CreativeGenerationError("O LLM devolveu um artigo ou cards de imagem incompletos.")
|
|
112
|
+
return save_post({**post, "title": title, "article_text": article, "images": normalized_images, "status": "imagens_pendentes"})
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _download_image(url: str, destination: Path) -> Path:
|
|
116
|
+
response = requests.get(url, timeout=45, headers={"User-Agent": "Thunderbolt Facebook Automation/1.0"})
|
|
117
|
+
response.raise_for_status()
|
|
118
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
destination.write_bytes(response.content)
|
|
120
|
+
return destination
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def collect_images(settings: dict[str, Any], post: Mapping[str, Any], *, source: str = "google") -> dict[str, Any]:
|
|
124
|
+
folder = Path(str(post.get("folder") or POSTS_DIR / str(post.get("id"))) / "images")
|
|
125
|
+
images = []
|
|
126
|
+
custom_key = str(settings.get("google_custom_search_api_key") or settings.get("google_images_api_key") or "").strip()
|
|
127
|
+
custom_cx = str(settings.get("google_custom_search_cx") or settings.get("google_images_cx") or "").strip()
|
|
128
|
+
for index, item in enumerate(post.get("images") or [], start=1):
|
|
129
|
+
query = str(item.get("search_query") or post.get("theme") or "").strip()
|
|
130
|
+
image_record = dict(item)
|
|
131
|
+
try:
|
|
132
|
+
destination = folder / f"image-{index}.jpg"
|
|
133
|
+
if source == "google" and custom_key and custom_cx:
|
|
134
|
+
payload = requests.get("https://www.googleapis.com/customsearch/v1", params={"key": custom_key, "cx": custom_cx, "q": query, "searchType": "image", "num": 1, "safe": "active"}, timeout=30).json()
|
|
135
|
+
link = str(((payload.get("items") or [{}])[0]).get("link") or "")
|
|
136
|
+
if not link:
|
|
137
|
+
raise ValueError("A pesquisa Google não devolveu uma imagem.")
|
|
138
|
+
_download_image(link, destination)
|
|
139
|
+
image_record["source"] = "google_images"
|
|
140
|
+
else:
|
|
141
|
+
cards = settings.get("media_provider_cards") if isinstance(settings.get("media_provider_cards"), list) else []
|
|
142
|
+
card = next((c for c in cards if isinstance(c, Mapping) and bool(c.get("enabled", True)) and str(c.get("kind") or c.get("type") or "image").lower() in {"image", "images", ""}), None)
|
|
143
|
+
if not card:
|
|
144
|
+
raise ValueError("Configure um provider de imagens IA em Configuração API.")
|
|
145
|
+
generated = generate_image_for_card(settings, card, query, topic=str(post.get("title") or post.get("theme") or "Facebook"), variant_index=index - 1)
|
|
146
|
+
destination = Path(generated)
|
|
147
|
+
image_record["source"] = "ai"
|
|
148
|
+
image_record["path"] = str(destination)
|
|
149
|
+
image_record["status"] = "imagem_baixada"
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
image_record["status"] = "erro"
|
|
152
|
+
image_record["error"] = str(exc)[:300]
|
|
153
|
+
images.append(image_record)
|
|
154
|
+
return save_post({**post, "images": images, "status": "legendas_pendentes" if any(item.get("path") for item in images) else "imagens_pendentes"})
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
158
|
+
candidates = ["/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf"]
|
|
159
|
+
for candidate in candidates:
|
|
160
|
+
if Path(candidate).is_file():
|
|
161
|
+
return ImageFont.truetype(candidate, size=size)
|
|
162
|
+
return ImageFont.load_default()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def caption_images(post: Mapping[str, Any]) -> dict[str, Any]:
|
|
166
|
+
updated = []
|
|
167
|
+
for index, item in enumerate(post.get("images") or [], start=1):
|
|
168
|
+
record = dict(item)
|
|
169
|
+
raw_path = str(record.get("path") or "")
|
|
170
|
+
if raw_path and Path(raw_path).is_file():
|
|
171
|
+
source = Path(raw_path)
|
|
172
|
+
output = source.with_name(f"captioned-{source.stem}.jpg")
|
|
173
|
+
with Image.open(source).convert("RGB") as image:
|
|
174
|
+
image.thumbnail((1600, 1600))
|
|
175
|
+
canvas = Image.new("RGB", image.size, "black")
|
|
176
|
+
canvas.paste(image, ((canvas.width - image.width) // 2, (canvas.height - image.height) // 2))
|
|
177
|
+
draw = ImageDraw.Draw(canvas)
|
|
178
|
+
text = str(record.get("overlay_text") or "").strip()
|
|
179
|
+
font = _font(max(28, min(64, canvas.width // 22)))
|
|
180
|
+
lines = textwrap.wrap(text, width=max(18, canvas.width // 32))
|
|
181
|
+
line_height = int(font.size * 1.2) if hasattr(font, "size") else 40
|
|
182
|
+
box_height = line_height * len(lines) + 48
|
|
183
|
+
y = canvas.height - box_height - 24
|
|
184
|
+
draw.rounded_rectangle((24, y, canvas.width - 24, canvas.height - 24), radius=18, fill=(0, 0, 0, 190))
|
|
185
|
+
for line in lines:
|
|
186
|
+
bbox = draw.textbbox((0, 0), line, font=font)
|
|
187
|
+
x = (canvas.width - (bbox[2] - bbox[0])) // 2
|
|
188
|
+
draw.text((x + 2, y + 16), line, font=font, fill="black")
|
|
189
|
+
draw.text((x, y + 14), line, font=font, fill="white")
|
|
190
|
+
y += line_height
|
|
191
|
+
canvas.save(output, "JPEG", quality=92)
|
|
192
|
+
record["captioned_path"] = str(output)
|
|
193
|
+
record["status"] = "legendada"
|
|
194
|
+
updated.append(record)
|
|
195
|
+
return save_post({**post, "images": updated, "status": "pronto_upload" if any(item.get("captioned_path") for item in updated) else "legendas_pendentes"})
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def publish_to_facebook(post: Mapping[str, Any], page: Mapping[str, Any]) -> dict[str, Any]:
|
|
199
|
+
page_id = str(page.get("page_id") or page.get("facebook_page_id") or "").strip()
|
|
200
|
+
token = str(page.get("access_token") or page.get("facebook_access_token") or "").strip()
|
|
201
|
+
if not page_id or not token:
|
|
202
|
+
raise ValueError("Configure o Page ID e o Access Token da Meta nesta página Facebook antes de publicar.")
|
|
203
|
+
message = f"{post.get('title') or post.get('theme') or ''}\n\n{post.get('article_text') or ''}".strip()
|
|
204
|
+
published = []
|
|
205
|
+
for item in post.get("images") or []:
|
|
206
|
+
path = Path(str(item.get("captioned_path") or item.get("path") or ""))
|
|
207
|
+
if not path.is_file():
|
|
208
|
+
continue
|
|
209
|
+
with path.open("rb") as handle:
|
|
210
|
+
response = requests.post(f"https://graph.facebook.com/v20.0/{page_id}/photos", data={"caption": message, "published": "false", "access_token": token}, files={"source": (path.name, handle, "image/jpeg")}, timeout=90)
|
|
211
|
+
payload = response.json() if response.content else {}
|
|
212
|
+
if response.status_code >= 400 or payload.get("error"):
|
|
213
|
+
raise ValueError(str(payload.get("error", {}).get("message") or f"Meta Graph API HTTP {response.status_code}"))
|
|
214
|
+
published.append(payload)
|
|
215
|
+
if not published:
|
|
216
|
+
raise ValueError("Não existe nenhuma imagem legendada para publicar.")
|
|
217
|
+
return save_post({**post, "status": "publicado", "published_at": _now(), "facebook_media_ids": published})
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
__all__ = ["POSTS_DIR", "caption_images", "collect_images", "create_post", "generate_article", "generate_theme", "list_posts", "publish_to_facebook", "save_post"]
|
|
@@ -96,6 +96,8 @@ def list_video_files(folder: str | Path) -> list[Path]:
|
|
|
96
96
|
def list_generated_videos(tasks: list[dict[str, Any]]) -> list[Path]:
|
|
97
97
|
paths: dict[str, Path] = {}
|
|
98
98
|
for task in tasks:
|
|
99
|
+
if "video_ready" in task and not bool(task.get("video_ready")):
|
|
100
|
+
continue
|
|
99
101
|
artifacts = task.get("artifacts") or {}
|
|
100
102
|
value = artifacts.get("video")
|
|
101
103
|
if isinstance(value, str) and value.strip():
|
package/package.json
CHANGED