@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 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, com um fluxo local inspirado no Clip Generator do OpenShorts.")
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 gerados com caminho registado na pipeline.")
4896
+ st.info("Ainda não existem vídeos prontos na pipeline.")
4873
4897
  else:
4874
- generated_labels = [f"{path.name} — {path}" for path in generated_paths]
4875
- selected_generated = st.selectbox("Vídeo gerado", range(len(generated_paths)), format_func=lambda index: generated_labels[index], key="cuts_generated_index")
4876
- if st.button("Usar vídeo seleccionado", key="cuts_use_generated", width="stretch"):
4877
- source_path = generated_paths[selected_generated]
4878
- st.session_state["cuts_source_path"] = str(source_path)
4879
- st.session_state["cuts_source_label"] = f"Pipeline · {source_path.name}"
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 gerados com caminho registado nos artefactos da pipeline.")
5067
+ st.info("Ainda não existem vídeos prontos nos artefactos da pipeline.")
5031
5068
  else:
5032
- labels = [f"{path.name} — {path}" for path in generated_paths]
5033
- selected_index = st.selectbox("Vídeo gerado", range(len(generated_paths)), format_func=lambda index: labels[index], key="python_editor_generated_index")
5034
- source_path = generated_paths[selected_index]
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": lambda: None,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.7.30",
3
+ "version": "0.7.32",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",