subapi / main.py
habulaj's picture
Update main.py
ff6ec0b verified
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse
from pydantic import BaseModel
import tempfile
from typing import Optional
from pathlib import Path
import os
import re
import requests
import time
import json
import subprocess
import asyncio
import sys
import datetime
from pathlib import Path
class ProcessLogger:
def __init__(self, agent_name: str, record_id: str = "N/A"):
self.agent_name = agent_name
self.record_id = record_id
self.start_time = time.time()
self.last_step_time = self.start_time
self.log("🚀 Iniciando processo...")
def log(self, message: str):
now = datetime.datetime.now()
abs_time = now.strftime("%H:%M:%S")
curr_time = time.time()
rel_time = curr_time - self.start_time
step_time = curr_time - self.last_step_time
self.last_step_time = curr_time
# Formata o prefixo para ser facilmente identificável no console do Hugging Face
prefix = f"[{abs_time}][{self.agent_name.upper()}][#{self.record_id}]"
timing = f"(total: {rel_time:.1f}s | passo: {step_time:.1f}s)"
print(f"{prefix} {timing} {message}")
sys.path.append(str(Path(__file__).parent.parent))
from detect_crop_image import detect_and_crop_image
from detect_crop_video import detect_and_crop_video, detect_and_crop_text
from gemini_webapi import GeminiClient
from gemini_webapi.constants import Model
from fastapi.staticfiles import StaticFiles
app = FastAPI(title="Gemini Chat API", description="API para interagir com Google Gemini (Nova versão)")
os.makedirs("static", exist_ok=True)
os.makedirs("static/processed", exist_ok=True)
app.mount("/static", StaticFiles(directory="static"), name="static")
# Cookies extraídos do Request Header (usar variáveis de ambiente para a nuvem)
Secure_1PSID = os.getenv("GEMINI_SECURE_1PSID", "PRIVATE")
Secure_1PSIDTS = os.getenv("GEMINI_SECURE_1PSIDTS", "PRIVATE")
client = None
@app.on_event("startup")
async def startup_event():
global client
print("Iniciando cliente do Gemini em plano de fundo...")
client = GeminiClient(Secure_1PSID, Secure_1PSIDTS, proxy=None)
# auto_refresh=True fará com que o token __Secure-1PSIDTS seja renovado automaticamente
await client.init(timeout=180, auto_close=False, close_delay=300, auto_refresh=True, watchdog_timeout=300)
print("Cliente Gemini-API inicializado com sucesso!")
@app.get("/")
def root():
return {"status": "ok", "message": "Gemini Chat API (gemini-webapi) está funcionando"}
def clean_and_validate_srt(srt_content):
if "```" in srt_content:
code_block_pattern = re.compile(r"```(?:srt)?\n(.*?)```", re.DOTALL | re.IGNORECASE)
match = code_block_pattern.search(srt_content)
if match:
srt_content = match.group(1).strip()
first_block_pattern = re.compile(r"^\s*\d+\s*\n\d{2}:\d{2}:\d{2},\d{3}", re.MULTILINE)
match = first_block_pattern.search(srt_content)
if match: srt_content = srt_content[match.start():]
pattern = re.compile(r"(\d+)\s*\n([^-\n]+?) --> ([^-\n]+?)\s*\n((?:(?!^\d+\s*\n).+\n?)*)", re.MULTILINE)
matches = pattern.findall(srt_content)
def corrigir_timestamp(timestamp):
timestamp = timestamp.strip()
if re.match(r"\d{2}:\d{2}:\d{2},\d{3}", timestamp): return timestamp
if re.match(r"\d{2}:\d{2},\d{3}", timestamp): return f"00:{timestamp}"
if re.match(r"\d{1}:\d{2},\d{3}", timestamp):
parts = timestamp.split(":")
return f"00:{parts[0].zfill(2)}:{parts[1]}"
if re.match(r"\d{1,2},\d{3}", timestamp):
seconds_ms = timestamp.split(",")
return f"00:00:{seconds_ms[0].zfill(2)},{seconds_ms[1]}"
if re.match(r"\d{2}:\d{2}:\d{3}", timestamp):
parts = timestamp.split(":")
if len(parts) == 3:
h, m, s_ms = parts
if len(s_ms) == 3: return f"{h}:{m}:00,{s_ms}"
elif len(s_ms) >= 4:
s, ms = s_ms[:-3], s_ms[-3:]
return f"{h}:{m}:{s.zfill(2)},{ms}"
return timestamp
srt_corrigido = ""
for i, (num, start, end, text) in enumerate(matches, 1):
text = text.strip()
if not text: continue
text_lines = [line.strip() for line in text.split('\n') if line.strip()]
if len(text_lines) > 2:
text = text_lines[0] + '\n' + ' '.join(text_lines[1:])
srt_corrigido += f"{i}\n{corrigir_timestamp(start)} --> {corrigir_timestamp(end)}\n{text}\n\n"
return srt_corrigido.strip()
def download_file_with_retry(url: str, max_retries: int = 3, timeout: int = 300, logger: Optional[ProcessLogger] = None):
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Accept': '*/*'
}
for attempt in range(max_retries):
try:
if attempt > 0:
if logger: logger.log(f"🔄 Tentativa {attempt + 1} de download...")
time.sleep(2 ** attempt)
response = requests.get(url, headers=headers, timeout=timeout, stream=True)
if response.status_code == 429:
wait_time = int(response.headers.get('Retry-After', (2 ** attempt) * 5))
time.sleep(wait_time)
continue
response.raise_for_status()
return response
except requests.exceptions.HTTPError as e:
if e.response.status_code == 429 and attempt < max_retries - 1: continue
elif attempt == max_retries - 1: raise HTTPException(status_code=400, detail=str(e))
except requests.exceptions.RequestException as e:
if attempt == max_retries - 1: raise HTTPException(status_code=400, detail=str(e))
raise HTTPException(status_code=400, detail="Falha ao baixar arquivo")
def extract_json_from_text(text: str):
text = text.strip()
if "```json" in text:
text = text.split("```json")[1].split("```")[0].strip()
elif "```" in text:
parts = text.split("```")
if len(parts) >= 2: text = parts[1].strip()
start_idx = text.find('[')
end_idx = text.rfind(']')
if start_idx != -1 and end_idx != -1: text = text[start_idx:end_idx+1]
else:
start_idx = text.find('{')
end_idx = text.rfind('}')
if start_idx != -1 and end_idx != -1: text = text[start_idx:end_idx+1]
text = re.sub(r',\s*([\]}])', r'\1', text)
try: return json.loads(text)
except: return None
def cut_video(input_path: str, output_path: str, start: str, end: str):
try:
command = ["ffmpeg", "-y", "-i", input_path, "-ss", start, "-to", end, "-c:v", "libx264", "-c:a", "aac", "-strict", "experimental", output_path]
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return True
except subprocess.CalledProcessError as e:
print(f"Erro ao cortar vídeo: {e.stderr.decode()}")
return False
def get_gemini_model(model_name: str):
"""
Mapeador que decide qual modelo do Gemini Client será utilizado
baseado na string antiga.
"""
model_name_lower = model_name.lower() if model_name else "flash"
if "thinking" in model_name_lower:
return Model.G_3_FLASH_THINKING_AI_FREE
elif "pro" in model_name_lower:
# Padrão ou o modelo mais inteligente que criamos
return Model.G_3_PRO_AI_FREE
# Default flash
return Model.G_3_FLASH_AI_FREE
# ==========================================
# ENDPOINTS
# ==========================================
class ChatRequest(BaseModel):
message: str
context: Optional[str] = None
model: Optional[str] = "flash"
@app.post("/chat")
async def chat_endpoint(request: ChatRequest):
if not client:
raise HTTPException(status_code=500, detail="Gemini client is not initialized")
try:
model_obj = get_gemini_model(request.model)
prompt = request.message
if request.context:
prompt = f"Contexto: {request.context}\n\nMensagem: {request.message}"
print(f"💬 Chat request ({request.model}): {prompt[:50]}...")
response = await client.generate_content(prompt, model=model_obj)
return {"response": response.text}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
class ProcessUrlRequest(BaseModel):
url: str
context: Optional[str] = ""
version: str # "recurvepop" ou "girlsmoodaily"
raw_url: Optional[bool] = True
text_cut: Optional[bool] = True
translate: Optional[bool] = True
@app.post("/process-url")
async def process_url_endpoint(request: ProcessUrlRequest, background_tasks: BackgroundTasks):
"""
Cria um registro inicial no Supabase e envia para processamento em background.
"""
supabase_url = os.getenv("SUPABASE_URL", "").rstrip("/")
supabase_key = os.getenv("SUPABASE_KEY", "")
if not supabase_url or not supabase_key:
raise HTTPException(status_code=500, detail="Credenciais do Supabase não configuradas no ambiente.")
headers = {
"apikey": supabase_key,
"Authorization": f"Bearer {supabase_key}",
"Content-Type": "application/json",
"Prefer": "return=representation"
}
# Inserir no Supabase com user_created=True
post_payload = {
"ig_post_url": request.url,
"ig_caption": request.context,
"account_target": request.version,
"user_created": True,
"published": False
}
res_post = requests.post(f"{supabase_url}/rest/v1/posts", headers=headers, json=post_payload, timeout=10)
if not res_post.ok:
raise HTTPException(status_code=500, detail=f"Erro ao criar registro no Supabase: {res_post.text}")
records = res_post.json()
record_id = records[0].get("id") if records else None
if not record_id:
raise HTTPException(status_code=500, detail="Registro criado, mas ID não retornado pelo Supabase.")
background_tasks.add_task(run_process_url, request, record_id)
return {"status": "ok", "message": "Solicitação em processamento no background.", "record_id": record_id}
async def run_process_url(request: ProcessUrlRequest, record_id: int):
"""
Processa um post do Instagram a partir de uma URL em background, salvando o resultado.
"""
if not client:
print("Gemini client is not initialized")
return
t_start = time.time()
print(f"🚀 [0.0s] Inciando process-url em background para Record: {record_id}")
temp_file = None
cropped_file_path = None
cropped_video_path = None
screenshot_path = None
exported_cropped_video_url = None
supabase_url = os.getenv("SUPABASE_URL", "").rstrip("/")
supabase_key = os.getenv("SUPABASE_KEY", "")
headers = {
"apikey": supabase_key,
"Authorization": f"Bearer {supabase_key}",
"Content-Type": "application/json"
}
try:
from agent_config import AGENTS
account = request.version
if account not in AGENTS:
raise Exception(f"Conta '{account}' não configurada.")
agent_conf = AGENTS[account]["process"]
agent_name = agent_conf["name"]
discord_id = agent_conf["discord_id"]
logger = ProcessLogger(agent_name, record_id)
# 1. Chamar a API externa para obter informações do post
logger.log(f"🔗 Buscando dados do post via API externa (process-url): {request.url}")
api_headers = {
"accept": "*/*",
"accept-language": "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7",
"content-type": "application/json",
"origin": "http://localhost:5173",
"referer": "http://localhost:5173/",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
}
api_payload = {"url": request.url, "videoQuality": "best"}
api_resp = requests.post(
"https://proud-paper-3751.fly.dev/api/json",
headers=api_headers,
json=api_payload,
timeout=60,
)
if not api_resp.ok:
raise Exception(f"Erro na API externa: {api_resp.text}")
api_data = api_resp.json()
video_url = api_data.get("url")
context = request.context or api_data.get("caption", "")
comments = api_data.get("comments", [])
if not video_url:
logger.log("❌ API externa não retornou URL de mídia.")
raise Exception("API externa não retornou URL de mídia.")
logger.log(f"✅ Mídia obtida: {video_url}")
# 2. Baixar mídia
logger.log(f"📥 Baixando mídia: {video_url}")
response = download_file_with_retry(video_url, timeout=600, logger=logger)
content_type = response.headers.get("content-type", "").lower()
is_image = "image" in content_type
post_type = "image" if is_image else "video"
# Atualizar type e ig_post_url com o que baixamos
patch_initial = {
"type": post_type,
"ig_post_url": video_url,
"ig_caption": context
}
requests.patch(f"{supabase_url}/rest/v1/posts?id=eq.{record_id}", headers=headers, json=patch_initial)
if is_image:
if "png" in content_type: ext = ".png"
elif "webp" in content_type: ext = ".webp"
else: ext = ".jpg"
else:
ext = ".webm" if "webm" in content_type else ".mp4"
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
for chunk in response.iter_content(chunk_size=1024 * 1024):
if chunk:
temp_file.write(chunk)
temp_file.close()
logger.log(f"💾 Download concluído: {temp_file.name} ({os.path.getsize(temp_file.name) / (1024*1024):.2f} MB)")
# 2.5 Verificação de Duração (Limite: 1 minuto)
if not is_image:
try:
probe = subprocess.run(
["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", temp_file.name],
capture_output=True, text=True, timeout=15
)
duration = float(probe.stdout.strip()) if probe.returncode == 0 and probe.stdout.strip() else 0
logger.log(f"⏱️ Duração do vídeo: {duration:.2f}s")
if duration > 60:
logger.log(f"🚫 Vídeo reprovado: {duration:.2f}s excede o limite de 1 minuto.")
filter_msg = f"Infelizmente não consigo processar esse vídeo. Ele tem {duration:.2f}s e o meu limite atual é de no máximo 1 minuto (60 segundos). Escolha um vídeo mais curto! ❌"
try:
import urllib.parse as _up
_reject_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + _up.urlencode({"mensagem": filter_msg, "id": discord_id})
requests.get("https://proxy.onrecurve.com/", params={"quest": _reject_url}, timeout=5)
except Exception as _e_dc:
logger.log(f"⚠️ Erro ao enviar Discord de rejeição por duração: {_e_dc}")
requests.patch(f"{supabase_url}/rest/v1/posts?id=eq.{record_id}", headers=headers, json={
"approved_filter": False,
"published": False,
"filter_message": f"Rejeitado automaticamente: Duração de {duration:.2f}s excede o limite de 60s.",
"result": {"error": f"O vídeo é muito longo ({duration:.2f}s). O limite é 60s."},
"contains_image": False
})
return
except Exception as e:
logger.log(f"⚠️ Erro ao verificar duração do vídeo: {e}")
video_path_to_analyze = temp_file.name
files_to_send = [] # Ordem: [cropped, original]
# 3. Crop
if is_image:
logger.log("✂️ Processando imagem: detectando região ativa...")
try:
cropped_file_path = detect_and_crop_image(video_path_to_analyze)
if cropped_file_path and os.path.exists(cropped_file_path):
logger.log(f"✅ Imagem cortada com sucesso: {cropped_file_path}")
files_to_send.append(cropped_file_path)
except Exception as e:
logger.log(f"⚠️ Erro ao cortar imagem: {e}")
else:
logger.log("✂️ Processando vídeo: detectando movimento e bordas...")
try:
# Criar arquivo temporário para o vídeo cortado (Passo 1: Motion Crop)
cropped_video_tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".mp4").name
cropped_video_path = cropped_video_tmp # Track for finally cleanup
# Sempre fazemos o motion crop primeiro sem o text_cut interno
crop_status = detect_and_crop_video(video_path_to_analyze, cropped_video_tmp, text_cut=False)
if crop_status == "success" and os.path.exists(cropped_video_tmp):
# Passo 2: Verificação via Gemini (Apenas se o usuário solicitou text_cut)
if request.text_cut:
logger.log("🧠 Verificando presença de textos externos via Gemini (process-url)...")
text_check_model = get_gemini_model("flash")
text_check_prompt = """Analise o vídeo anexado e determine se há textos de edição externa (overlays de terceiros) adicionados após a produção original ou profissional do conteúdo.
Retorne um JSON no formato: {"has_texts": true/false}
Regras de Classificação:
- Considere como FALSE (Não marcar como texto de edição):
- Gráficos de Transmissão: Nomes de indicados, categorias de premiação, placares esportivos ou "lower thirds" que fazem parte da transmissão oficial da TV/Evento.
- Créditos Artísticos/Branding Profissional: Logotipos de marcas (ex: "Dior") ou créditos integrados que fazem parte da peça audiovisual original ou de um "lookbook" profissional.
- Textos de Ambiente: Placas, telões no fundo do palco ou escritos em roupas.
- Estética Profissional: Qualquer texto que tenha sido claramente renderizado como parte da master original do vídeo.
- Considere como TRUE (Marcar como texto de edição):
- Legendas de Redes Sociais: Captions explicativas ou legendas automáticas adicionadas para visualização em redes sociais.
- Textos de Engajamento: Frases como "Assista até o final", "O que você achou?", emojis flutuantes ou textos genéricos de meme.
- Marcas d'água de editores externos: Textos que identificam perfis de usuários ou editores que não são os produtores originais do evento.
Objetivo: O foco é detectar se o vídeo foi "re-editado" com textos informativos/estáticos típicos de redes sociais. Se o texto parece vir da produção original (Oscar, marcas de luxo, TV), retorne false. Responda apenas com o JSON, sem explicações."""
try:
text_resp = await client.generate_content(text_check_prompt, files=[cropped_video_tmp], model=text_check_model)
text_data = extract_json_from_text(text_resp.text)
has_texts = text_data.get("has_texts", False) if isinstance(text_data, dict) else False
if has_texts:
logger.log("🔍 Gemini detectou textos de edição externa. Iniciando OCR...")
ocr_video_tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".mp4").name
text_crop_status = detect_and_crop_text(cropped_video_tmp, ocr_video_tmp)
if text_crop_status == "aborted_area_too_small":
logger.log("🚫 Crop abortado: área de texto no centro (segurança).")
# Notificação do Agente no Discord (Octavio/Diana)
try:
import urllib.parse as _up
reject_msg = f"Analisando aqui, notei que o texto desse vídeo está bem no centro. Se eu cortasse agora, ia acabar destruindo o conteúdo principal. Como a gente preza pela qualidade da página, preferi reprovar esse aqui automaticamente. ❌"
_reject_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + _up.urlencode({"mensagem": reject_msg, "id": discord_id})
requests.get("https://proxy.onrecurve.com/", params={"quest": _reject_url}, timeout=5)
except Exception as _e_dc:
logger.log(f"⚠️ Erro ao enviar Discord de rejeição automática: {_e_dc}")
requests.patch(f"{supabase_url}/rest/v1/posts?id=eq.{record_id}", headers=headers, json={
"approved_filter": False,
"filter_message": "Rejeitado automaticamente: Região útil de texto insuficiente para crop seguro (texto centralizado).",
"result": {"error": "Rejeitado automaticamente: Região útil de texto insuficiente para crop seguro (texto centralizado). Tente outro vídeo."},
"contains_image": False
})
if os.path.exists(cropped_video_tmp): os.unlink(cropped_video_tmp)
if os.path.exists(ocr_video_tmp): os.unlink(ocr_video_tmp)
return
elif text_crop_status == "success" and os.path.exists(ocr_video_tmp):
if os.path.exists(cropped_video_tmp): os.unlink(cropped_video_tmp)
cropped_video_tmp = ocr_video_tmp
cropped_video_path = ocr_video_tmp # Update tracking
logger.log(f"✅ Vídeo cortado via OCR com sucesso em: {cropped_video_tmp}")
else:
if os.path.exists(ocr_video_tmp): os.unlink(ocr_video_tmp)
logger.log("⚠️ Crop de OCR ignorado ou falhou, seguindo com motion crop.")
else:
logger.log("✅ Gemini NÃO detectou textos de social media. Pulando OCR.")
except Exception as ge:
logger.log(f"⚠️ Erro na análise Gemini de textos: {ge}. Seguindo apenas com motion crop.")
# Final path assignment
cropped_video_path = cropped_video_tmp
logger.log(f"✅ Vídeo (motion/ocr) consolidado em: {cropped_video_path}")
else:
cropped_video_path = None
logger.log("⚠️ Crop de vídeo ignorado ou falhou.")
if os.path.exists(cropped_video_tmp): os.unlink(cropped_video_tmp)
except Exception as e:
cropped_video_path = None
logger.log(f"⚠️ Erro no processo de crop: {e}")
# Adiciona o vídeo original (sempre em segundo se houver crop)
files_to_send.append(video_path_to_analyze)
# 4. Montar contextos
contexto_add = f"\n{context}" if context else ""
comentarios_add = ""
if comments:
comentarios_add = "\nCOMENTÁRIOS DO POST (Use como forte inspiração para criar títulos mais reais e humanizados):\n"
for c in comments:
if isinstance(c, dict) and (text := c.get("text", "").strip()):
comentarios_add += f"- {text} ({c.get('like_count', 0)} curtidas)\n"
if is_image:
tipo_conteudo_add = "\n\nCONTEXTO DO CONTEÚDO: Este post é uma IMAGEM. O título vai aparecer em cima da imagem."
else:
tipo_conteudo_add = ""
filter_message_add = ""
prompt = agent_conf["get_prompt"](
date_str=time.strftime("%d/%m/%Y"),
contexto_add=contexto_add,
comentarios_add=comentarios_add,
tipo_conteudo_add=tipo_conteudo_add,
filter_message_add=filter_message_add,
)
# 5. Gerar com Gemini
# Função helper para screenshot
async def do_screenshot_and_upload(vid_path: str):
def create_screenshot_sync():
ss_path = tempfile.NamedTemporaryFile(delete=False, suffix=".jpg").name
try:
probe = subprocess.run(
["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", vid_path],
capture_output=True, text=True
)
vid_dur = float(probe.stdout.strip()) if probe.returncode == 0 and probe.stdout.strip() else 10.0
mid_time = str(vid_dur / 2)
ff_ss = subprocess.run(
["ffmpeg", "-y", "-i", vid_path, "-ss", mid_time, "-frames:v", "1", ss_path],
capture_output=True, text=True
)
if ff_ss.returncode == 0:
with open(ss_path, "rb") as sf:
res = requests.post(
"https://habulaj-recurve-save.hf.space/upload",
files={"files": ("screenshot.jpg", sf, "image/jpeg")},
timeout=60,
)
res.raise_for_status()
return res.json().get("url", ""), vid_dur, ss_path
except Exception as e:
logger.log(f"⚠️ Erro background screenshot: {e}")
return None, 10.0, ss_path
return await asyncio.to_thread(create_screenshot_sync)
async def do_groq_trans(vid_url: str, local_file: str):
try:
s_raw, _, _, _ = await get_groq_srt_base(
vid_url, language=None, temperature=0.4, has_bg_music=False, local_path=local_file
)
return s_raw
except Exception as e:
logger.log(f"⚠️ Erro background Groq: {e}")
return None
model_obj = get_gemini_model("flash")
logger.log(f"🧠 Enviando {len(files_to_send)} arquivos para Gemini (flash) [{agent_name}] process-url...")
gemini_task = asyncio.create_task(client.generate_content(prompt, files=files_to_send, model=model_obj))
screenshot_task = None
groq_task = None
video_for_export = None
if not is_image:
video_for_export = cropped_video_path if cropped_video_path and os.path.exists(cropped_video_path) else temp_file.name
logger.log("⚙️ Iniciando Screenshot e Transcrição (Groq) em background...")
screenshot_task = asyncio.create_task(do_screenshot_and_upload(video_for_export))
groq_task = asyncio.create_task(do_groq_trans(video_url, video_for_export))
response_gemini = await gemini_task
logger.log("✨ Resposta do Gemini recebida.")
titles_data = extract_json_from_text(response_gemini.text)
if not titles_data:
logger.log("❌ Falha ao processar JSON da resposta do Gemini.")
return
result_json = titles_data if isinstance(titles_data, list) else [titles_data]
# 6. Video export
final_content_url = None
srt_for_export = None
# ATENÇÃO: NÃO REINICIALIZAR exported_cropped_video_url AQUI PARA NÃO PERDER O UPLOAD ANTECIPADO
if result_json:
result_data = result_json[0] if isinstance(result_json[0], dict) else {}
title_text = result_data.get("title", "")
if not is_image and title_text:
title_text = title_text[0].upper() + title_text[1:] if title_text else title_text
try:
if screenshot_task:
screenshot_url, video_duration, screenshot_temp_path = await screenshot_task
screenshot_path = screenshot_temp_path
else:
screenshot_url, video_duration, screenshot_temp_path = None, 10.0, None
if screenshot_url:
print("📸 Screenshot recebido da task background.")
if screenshot_url:
# 3. Upload do vídeo cortado para recurve-save (Se ainda não foi feito)
if (video_for_export != temp_file.name) and not exported_cropped_video_url:
print(f"☁️ [{time.time()-t_start:.1f}s] Enviando vídeo cortado para recurve-save (fallback)...")
def upload_crop():
with open(video_for_export, "rb") as vf:
vid_upload_resp = requests.post(
"https://habulaj-recurve-save.hf.space/upload",
files={"files": ("cropped_video.mp4", vf, "video/mp4")},
data={"long_duration": "yes"},
timeout=120,
)
vid_upload_resp.raise_for_status()
return vid_upload_resp.json().get("url", "")
exported_cropped_video_url = await asyncio.to_thread(upload_crop)
export_video_url = exported_cropped_video_url if exported_cropped_video_url else video_url
print(f"📏 [{time.time()-t_start:.1f}s] Preparando export...")
import cv2
cap = cv2.VideoCapture(video_for_export)
crop_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) if cap.isOpened() else 1080
crop_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) if cap.isOpened() else 1920
cap.release()
canvas_width = 1080
image_height = 616
video_dest_height = 1920 - image_height
video_template = "horizontal" if crop_w > crop_h else "vertical"
if video_template == "vertical":
scaled_h = int(crop_h * canvas_width / crop_w) if crop_w > 0 else crop_h
video_x, video_y = 0, max(0, (scaled_h - video_dest_height) // 2)
else:
video_x, video_y = 0, 0
# Legendas
needs_legenda = result_data.get("legenda", False)
if needs_legenda and request.translate:
print(f"🎙️ [{time.time()-t_start:.1f}s] Resolvendo legendas (Groq task + Gemini translate)...")
try:
if groq_task:
srt_raw = await groq_task
else:
srt_raw = None
if srt_raw and srt_raw.strip():
translate_prompt = f"""IDIOMA: A legenda traduzida DEVE ser inteiramente em PORTUGUÊS DO BRASIL (pt-BR). Independente do idioma original do vídeo.
Traduza essa legenda pro português do Brasil, corrija qualquer erro de formatação, pontuação e mantenha timestamps e os textos nos seus respectivos blocos de legenda.
Você DEVE se basear estritamente na legenda original fornecida. NUNCA crie legendas novas e NUNCA adicione ou verifique diálogos no áudio que não estejam presentes na legenda original. Apenas traduza.
Deve traduzir exatamente o texto da legenda observando o contexto, não é pra migrar, por exemplo, textos de um bloco de legenda pra outro. Deve traduzir exatamente o texto de cada bloco de legenda, manter sempre as palavras, nunca retirar.
Mande o SRT completo, sem textos adicionais na resposta, apenas o SRT traduzido. A legenda acima é uma base gerada pelo Whisper que precisa ser limpa e traduzida, não o resultado final.
A legenda deve ser totalmente traduzida corretamente analisando o contexto e a entonação de falar. Adapte gírias e qualquer coisa do tipo. Não deve ser literal a tradução, deve se adaptar.
MÚSICA E LETRAS:
- NUNCA LEGENDE MÚSICAS OU CANÇÕES.
- Se houver música de fundo ou pessoas cantando uma música, IGNORE COMPLETAMENTE e não inclua na legenda.
- VOCÊ DEVE LEGENDAR APENAS DIÁLOGOS E FALAS REAIS.
PALAVRÕES E CENSURA:
- Você DEVE censurar palavras de baixo calão e palavrões pesados utilizando asteriscos.
- Substitua a maior parte da palavra censurada e mantenha apenas as primeiras letras.
- Exemplo: "filha da puta" se torna "filha da pu**", "caralho" se torna "caral**", "merda" se torna "merd*", "foda" se torna "fod*".
EXTREMAMENTE IMPORTANTE: NUNCA legende músicas (quando detectar que é uma música, não legende), apenas diálogos falados. Nunca altere o timing das legendas, deve ser exatamente igual ao original de referência. Nunca legende ações também, exemplo: [Música alta], [Música de encerramento], etc. Deve ser apenas, unicamente, diálogo humano.
EXEMPLO:
(Original): 1
00:00:01,000 --> 00:00:04,000
hey what are you doing here i thought you left already
2
00:00:04,500 --> 00:00:07,200
yeah i was going to but then i realized i forgot my keys
3
00:00:07,900 --> 00:00:10,500
you always forget something man this is crazy
4
00:00:11,000 --> 00:00:14,000
relax it's not a big deal stop acting like that
5
00:00:14,500 --> 00:00:17,800
i am not acting you said you would be on time
6
00:00:18,000 --> 00:00:21,500
okay okay i'm sorry can we just go now
7
00:00:22,000 --> 00:00:25,000
fine but if we are late again you are a son of a bitch
(Traduzido, como você deveria traduzir): 1
00:00:01,000 --> 00:00:04,000
Ué, o que você tá fazendo aqui? Não era pra você já ter ido embora?
2
00:00:04,500 --> 00:00:07,200
Eu ia, mas aí percebi que esqueci minhas chaves.
3
00:00:07,900 --> 00:00:10,500
Cara, você sempre esquece alguma coisa, isso é surreal!
4
00:00:11,000 --> 00:00:14,000
Ah, relaxa! Não é o fim do mundo, para de drama.
5
00:00:14,500 --> 00:00:17,800
Não é drama! Você falou que ia chegar no horário!
6
00:00:18,000 --> 00:00:21,500
Tá, tá... foi mal. Bora logo?
7
00:00:22,000 --> 00:00:25,000
Tá bom, mas se a gente se atrasar de novo, você é um filha da pu**!
LEGENDA ORIGINAL:
{srt_raw}"""
translate_model = get_gemini_model("flash")
translate_resp = await client.generate_content(translate_prompt, model=translate_model)
translated_srt = translate_resp.text.strip()
if "```" in translated_srt:
parts = translated_srt.split("```")
for part in parts:
clean = part.strip()
if clean.startswith("srt"): clean = clean[3:].strip()
if re.match(r"\d+\s*\n\d{2}:", clean):
translated_srt = clean
break
if translated_srt:
srt_for_export = translated_srt
except Exception as sub_e:
print(f"⚠️ Erro ao gerar legendas: {sub_e}")
import urllib.parse
title_params = urllib.parse.urlencode({"text": title_text, "version": account})
title_url = f"https://habulaj-recurve-api-img.hf.space/cover/title?{title_params}&image_url={screenshot_url}"
result_json[0]["title_url"] = title_url
export_payload = {
"video_url": export_video_url,
"title_url": title_url,
"image_height": image_height,
"cut_start": 0,
"cut_end": video_duration,
"video_x": video_x,
"video_y": video_y,
"video_width": crop_w,
"video_height": crop_h,
"video_template": video_template,
}
if srt_for_export:
export_payload["subtitles"] = srt_for_export
print("🎬 Chamando video export API...")
export_resp = requests.post(
"https://habulaj-recurve-videos-export.hf.space/video/export",
json=export_payload,
timeout=600,
)
if not export_resp.ok:
raise Exception(f"API de video export falhou: {export_resp.status_code}: {export_resp.text}")
export_data = export_resp.json()
final_content_url = export_data.get("video_url")
logger.log(f"✅ Vídeo exportado: {final_content_url}")
except Exception as ve:
logger.log(f"⚠️ Erro no video export: {ve}")
raise Exception(f"Falha no video export: {ve}")
elif is_image and title_text and result_data.get("result_type") == "meme":
try:
img_for_meme = (
cropped_file_path
if cropped_file_path and os.path.exists(cropped_file_path)
else temp_file.name
)
import urllib.parse
meme_params = urllib.parse.urlencode({"text": title_text})
final_content_url = f"https://habulaj-recurve-api-img.hf.space/meme?{meme_params}"
result_json[0]["title_url"] = final_content_url
logger.log(f"✅ Meme URL gerada: {final_content_url}")
except Exception as e:
logger.log(f"⚠️ Erro ao gerar URL do meme: {e}")
if result_json and srt_for_export and isinstance(result_json[0], dict):
result_json[0]["subtitle_srt"] = srt_for_export
if final_content_url and isinstance(result_json[0], dict):
result_json[0]["final_content_url"] = final_content_url
# Salvar o resultado no banco
patch_final = {
"result": result_json
}
if final_content_url:
patch_final["final_content_url"] = final_content_url
if exported_cropped_video_url:
patch_final["crop_content_url"] = exported_cropped_video_url
requests.patch(f"{supabase_url}/rest/v1/posts?id=eq.{record_id}", headers=headers, json=patch_final)
logger.log(f"✅ Process-url concluído com sucesso!")
except Exception as e:
logger.log(f"⚠️ Erro em run_process_url background: {e}")
try:
error_message = str(e)
requests.patch(f"{supabase_url}/rest/v1/posts?id=eq.{record_id}", headers=headers, json={"result": {"error": error_message}})
except Exception as patch_e:
logger.log(f"⚠️ Erro ao salvar erro no Supabase: {patch_e}")
finally:
if temp_file and os.path.exists(temp_file.name): os.unlink(temp_file.name)
if cropped_file_path and os.path.exists(cropped_file_path): os.unlink(cropped_file_path)
if cropped_video_path and os.path.exists(cropped_video_path): os.unlink(cropped_video_path)
if screenshot_path and os.path.exists(screenshot_path): os.unlink(screenshot_path)
@app.api_route("/process/{account}", methods=["GET", "POST"])
async def process_account_endpoint(account: str):
if not client:
raise HTTPException(status_code=500, detail="Gemini client is not initialized")
temp_file = None
cropped_file_path = None
cropped_video_path = None
screenshot_path = None
record_id = None
cropped_video_url = None
try:
supabase_url = os.getenv("SUPABASE_URL", "").rstrip("/")
supabase_key = os.getenv("SUPABASE_KEY", "")
if not supabase_url or not supabase_key:
raise HTTPException(status_code=500, detail="Credenciais do Supabase não configuradas no ambiente.")
headers = {
"apikey": supabase_key,
"Authorization": f"Bearer {supabase_key}",
"Content-Type": "application/json"
}
from agent_config import AGENTS
if account not in AGENTS:
raise HTTPException(status_code=400, detail="Conta não configurada.")
agent_conf = AGENTS[account]["process"]
agent_name = agent_conf["name"]
discord_id = agent_conf["discord_id"]
system_discord_id = AGENTS[account].get("system_discord_id", 0)
# Buscar 1 post aprovado pelo filtro mas ainda não processado
select_url = f"{supabase_url}/rest/v1/posts?select=*&account_target=eq.{account}&approved_filter=eq.true&result=is.null&user_created=eq.false&limit=1"
res_get = requests.get(select_url, headers=headers, timeout=10)
if not res_get.ok:
raise HTTPException(status_code=500, detail=f"Erro ao ler posts: {res_get.text}")
records = res_get.json()
if not records:
publish_res = await safe_call_publish(account)
return {"status": "ok", "message": "Nenhuma postagem pendente para ser processada.", "publish_result": publish_res}
t_start = time.time()
record = records[0]
record_id = record.get("id")
logger = ProcessLogger(agent_name, record_id)
video_url = record.get("ig_post_url")
crop_url = record.get("crop_content_url")
context = record.get("ig_caption", "")
comments = record.get("comments") # Se existir no banco, pode ser uma lista
contains_image = record.get("contains_image", False)
filter_message = record.get("filter_message", "")
shortcode = record.get("ig_id")
if not comments and shortcode:
try:
# Chama a API do worker para pegar os comentários se for necessário
logger.log(f"📥 Buscando comentários do post {shortcode} via Bot Worker...")
bot_worker_url = "https://bot.arthurmribeiro51.workers.dev/comments"
c_res = requests.get(bot_worker_url, params={"shortcode": shortcode}, timeout=15)
if c_res.ok:
c_data = c_res.json()
fetched_comments = c_data.get("comments", [])
if fetched_comments:
comments = fetched_comments
logger.log(f"✅ Encontrado {len(comments)} comentários.")
except Exception as e:
logger.log(f"⚠️ Erro ao buscar comentários: {e}")
if not video_url:
raise HTTPException(status_code=400, detail=f"Registro ID {record_id} falhou: ig_post_url inválida.")
try:
import urllib.parse as _up
_sys_msg = f"🎨 **{agent_name}** começou a processar uma postagem...\n\n📎 **Mídia:** {video_url}"
_sys_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + _up.urlencode({"mensagem": _sys_msg, "id": system_discord_id})
requests.get("https://proxy.onrecurve.com/", params={"quest": _sys_url}, timeout=5)
except Exception as _e:
logger.log(f"⚠️ Erro ao enviar notificação de início para o Discord: {_e}")
logger.log(f"📥 Baixando vídeo: {crop_url if crop_url else video_url}")
try:
response = download_file_with_retry(crop_url if crop_url else video_url, timeout=600, logger=logger)
except HTTPException as he:
logger.log(f"🚫 Falha ao baixar a mídia ({he.detail}).")
err_msg = str(he.detail)
reject_msg = f"Essa postagem possui um link de mídia quebrado ou expirado (Provavelmente erro 403 Forbidden ou não é um arquivo/vídeo válido). Como não consegui baixar, estou rejeitando automaticamente. ❌"
try:
import urllib.parse as _up
_reject_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + _up.urlencode({"mensagem": reject_msg, "id": discord_id})
requests.get("https://proxy.onrecurve.com/", params={"quest": _reject_url}, timeout=5)
except Exception as _e_dc:
logger.log(f"⚠️ Erro ao enviar Discord de rejeição automática de mídia: {_e_dc}")
requests.patch(f"{supabase_url}/rest/v1/posts?id=eq.{record_id}", headers=headers, json={
"approved_filter": False,
"published": False,
"filter_message": f"Rejeitado automaticamente pela API: Link de mídia quebrado ou bloqueado ({err_msg}).",
"result": {"error": f"Erro de mídia: {err_msg}"},
"contains_image": False
})
return {"status": "rejected", "message": "Postagem rejeitada por falha no download.", "publish_result": None}
content_type = response.headers.get('content-type', '').lower()
if 'image' in content_type:
if 'png' in content_type: ext = '.png'
elif 'webp' in content_type: ext = '.webp'
else: ext = '.jpg'
else:
ext = '.webm' if 'webm' in content_type else '.mp4'
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
for chunk in response.iter_content(chunk_size=1024*1024):
if chunk: temp_file.write(chunk)
temp_file.close()
print(f"📥 [{time.time()-t_start:.1f}s] Download concluído.")
video_path_to_analyze = temp_file.name
files_to_send = [] # Ordem: [cropped, original]
temp_file_original = None
# 3. Lógica de Crop (Pular se já baixamos a versão cortada)
if crop_url:
print(f"✨ [{time.time()-t_start:.1f}s] Usando vídeo já cortado (crop_content_url). Pulando processamento de crop.")
cropped_video_path = video_path_to_analyze
cropped_video_url = crop_url
files_to_send.append(cropped_video_path)
# Adicionar o original como segundo anexo para contexto
print(f"📥 [{time.time()-t_start:.1f}s] Baixando vídeo original para contexto Gemini...")
try:
orig_resp = download_file_with_retry(video_url, timeout=300)
orig_temp = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
for chunk in orig_resp.iter_content(chunk_size=1024*1024):
if chunk: orig_temp.write(chunk)
orig_temp.close()
files_to_send.append(orig_temp.name)
# Adicionar à lista de limpeza final
temp_file_original = orig_temp # Guardar para cleanup
except Exception as e:
print(f"⚠️ Não foi possível baixar o vídeo original para contexto: {e}")
else:
# Lógica tradicional de crop
if 'image' in content_type:
print(f"✂️ [{time.time()-t_start:.1f}s] Processando imagem: detectando e cortando...")
try:
cropped_file_path = detect_and_crop_image(video_path_to_analyze)
if cropped_file_path and os.path.exists(cropped_file_path):
files_to_send.append(cropped_file_path)
except Exception as e:
print(f"⚠️ Erro ao cortar imagem: {e}")
else:
# Vídeo: detectar e cortar bordas estáticas
print(f"✂️ [{time.time()-t_start:.1f}s] Processando vídeo: detectando e cortando bordas...")
try:
cropped_video_path = tempfile.NamedTemporaryFile(delete=False, suffix='.mp4').name
crop_status = detect_and_crop_video(video_path_to_analyze, cropped_video_path)
if crop_status == "aborted_area_too_small":
print(f"🚫 [{time.time()-t_start:.1f}s] Crop abortado: área de texto no centro. Negando filtro.")
requests.patch(f"{supabase_url}/rest/v1/posts?id=eq.{record_id}", headers=headers, json={
"approved_filter": False,
"result": {"error": "Rejeitado automaticamente: Região útil de texto insuficiente para crop seguro (texto centralizado). Tente outro vídeo."}
})
return {"status": "rejected", "message": "Postagem rejeitada por crop insuficiente."}
if crop_status == "success" and os.path.exists(cropped_video_path):
files_to_send.append(cropped_video_path)
print(f"✅ [{time.perf_counter()-t_start:.1f}s] Vídeo cortado com sucesso.")
# Upload antecipado para recurve-save (Performance)
print(f"☁️ [{time.time()-t_start:.1f}s] Enviando vídeo cortado antecipadamente para recurve-save...")
with open(cropped_video_path, 'rb') as vf:
vid_upload_resp = requests.post(
"https://habulaj-recurve-save.hf.space/upload",
files={'files': ('cropped_video.mp4', vf, 'video/mp4')},
data={'long_duration': 'yes'},
timeout=120
)
vid_upload_resp.raise_for_status()
cropped_video_url = vid_upload_resp.json().get("url", "")
print(f"✅ [{time.time()-t_start:.1f}s] URL do vídeo cortado: {cropped_video_url}")
else:
cropped_video_path = None
print(f"⚠️ [{time.time()-t_start:.1f}s] Crop de vídeo não gerou resultado, seguindo com original apenas")
except Exception as e:
cropped_video_path = None
print(f"⚠️ Erro ao cortar vídeo: {e}")
# Adiciona o vídeo original (sempre em segundo se houver crop)
files_to_send.append(video_path_to_analyze)
contexto_add = f"\n{context}" if context else ""
comentarios_add = ""
if comments:
comentarios_add = "\nCOMENTÁRIOS DO POST (Use como forte inspiração para criar títulos mais reais e humanizados):\n"
for c in comments:
if isinstance(c, dict) and (text := c.get("text", "").strip()):
comentarios_add += f"- {text} ({c.get('like_count', 0)} curtidas)\n"
# Contexto de imagem passado para a VICKY
if 'image' in content_type:
if contains_image:
tipo_conteudo_add = "\n\nCONTEXTO DO CONTEÚDO: Este post é uma IMAGEM COM IMAGEM DE APOIO (foto, ilustração, meme visual). O título vai aparecer em cima da imagem. Adapte o título para funcionar junto com a imagem que o leitor vai ver logo abaixo."
else:
tipo_conteudo_add = "\n\nCONTEXTO DO CONTEÚDO: Este post é uma IMAGEM APENAS DE TEXTO (print de tweet, frase em fundo sólido, conversa, lista de texto, etc). Não há nenhuma imagem de apoio visual. O título É o conteúdo completo, ele vai aparecer sozinho na tela como o post inteiro.\n\nNESSE CASO: o título deve ser uma ADAPTAÇÃO FIEL do texto original. Não crie um resumo, não crie um título que descreva o tema por cima. Adapte o conteúdo em si pra soar natural em português brasileiro. Por exemplo: se o texto original for uma lista de características de uma pessoa, o título deve TRAZER ESSA LISTA adaptada, não um título dizendo 'o manual de sobrevivência da gata de janeiro'. Se for uma frase, adapte a frase. Se for uma conversa, adapte a conversa. O resultado deve ser o próprio conteúdo original, só que em português e adaptado culturalmente."
else:
tipo_conteudo_add = ""
filter_message_add = f"\n\nO QUE O FILTRO DISSE SOBRE ESTE CONTEÚDO (use como contexto extra): {filter_message}" if filter_message else ""
# Função helper para screenshot
async def do_screenshot_and_upload_acct(vid_path: str):
def create_screenshot_acct_sync():
ss_path = tempfile.NamedTemporaryFile(delete=False, suffix=".jpg").name
try:
probe = subprocess.run(
["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", vid_path],
capture_output=True, text=True
)
vid_dur = float(probe.stdout.strip()) if probe.returncode == 0 and probe.stdout.strip() else 10.0
mid_time = str(vid_dur / 2)
ff_ss = subprocess.run(
["ffmpeg", "-y", "-i", vid_path, "-ss", mid_time, "-vframes", "1", ss_path],
capture_output=True, text=True
)
if ff_ss.returncode != 0 or not os.path.exists(ss_path) or os.path.getsize(ss_path) == 0:
# Fallback caso a extração no meio falhe
ff_ss = subprocess.run(
["ffmpeg", "-y", "-i", vid_path, "-vframes", "1", ss_path],
capture_output=True, text=True
)
if ff_ss.returncode == 0 and os.path.exists(ss_path) and os.path.getsize(ss_path) > 0:
with open(ss_path, "rb") as sf:
res = requests.post(
"https://habulaj-recurve-save.hf.space/upload",
files={"files": ("screenshot.jpg", sf, "image/jpeg")},
timeout=60,
)
res.raise_for_status()
return res.json().get("url", ""), vid_dur, ss_path
else:
logger.log(f"⚠️ ffprobe/ffmpeg falhou ao extrair frame de {vid_path}. RC={ff_ss.returncode}, stderr={ff_ss.stderr.strip()[:150]}")
except Exception as e:
logger.log(f"⚠️ Erro background screenshot (acct): {e}")
return None, 10.0, ss_path
return await asyncio.to_thread(create_screenshot_acct_sync)
async def do_groq_trans_acct(vid_url: str, local_file: str):
try:
s_raw, _, _, _ = await get_groq_srt_base(
vid_url, language=None, temperature=0.4, has_bg_music=False, local_path=local_file
)
return s_raw
except Exception as e:
print(f"⚠️ Erro background Groq (acct): {e}")
return None
prompt = agent_conf["get_prompt"](
date_str=time.strftime('%d/%m/%Y'),
contexto_add=contexto_add,
comentarios_add=comentarios_add,
tipo_conteudo_add=tipo_conteudo_add,
filter_message_add=filter_message_add
)
model_name = record.get("model", "flash") # Tenta pegar do banco, senão flash
model_obj = get_gemini_model(model_name)
print(f"🧠 [{time.time()-t_start:.1f}s] Enviando para Gemini ({model_name}) para processamento...")
gemini_task = asyncio.create_task(client.generate_content(prompt, files=files_to_send, model=model_obj))
screenshot_task = None
groq_task = None
video_for_export = None
if 'image' not in content_type:
video_for_export = cropped_video_path if cropped_video_path and os.path.exists(cropped_video_path) else temp_file.name
print(f"⚙️ [{time.time()-t_start:.1f}s] Iniciando Screenshot e Transcrição (Groq) em background...")
screenshot_task = asyncio.create_task(do_screenshot_and_upload_acct(video_for_export))
# Para export_video_url usaremos cropped_video_url ou video_url (que já fizemos upload antecipado)
ev_url = cropped_video_url if cropped_video_url else video_url
groq_task = asyncio.create_task(do_groq_trans_acct(ev_url, video_for_export))
response_gemini = await gemini_task
print(f"✨ [{time.time()-t_start:.1f}s] Resposta do Gemini recebida.")
titles_data = extract_json_from_text(response_gemini.text)
if not titles_data:
return JSONResponse(content={"raw_content": response_gemini.text, "error": "Failed to parse JSON"}, status_code=200)
result_json = titles_data if isinstance(titles_data, list) else [titles_data]
# ETAPA IMEDIATA: Notificar Discord (Vicky) e salvar Títulos no Supabase
try:
print(f"📢 [{time.time()-t_start:.1f}s] Notificando Discord sobre criação do conteúdo...")
import urllib.parse as _up
_r0 = result_json[0] if result_json else {}
process_natural_msg = _r0.get("process_message", "Oi! Acabei de criar o conteúdo e as legendas. Agora estou preparando o vídeo final... ⏳")
# Vicky manda a mensagem natural dela
vicky_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + _up.urlencode({"mensagem": process_natural_msg, "id": discord_id})
requests.get("https://proxy.onrecurve.com/", params={"quest": vicky_url}, timeout=5)
# Atualizar Supabase com dados parciais (Títulos/Descrição)
requests.patch(f"{supabase_url}/rest/v1/posts?id=eq.{record_id}", headers=headers, json={"result": result_json}, timeout=10)
except Exception as _early_dc:
print(f"⚠️ Erro na notificação imediata do Discord: {_early_dc}")
final_content_url = None
srt_for_export = None
# ATENÇÃO: NÃO REINICIALIZAR cropped_video_url AQUI PARA NÃO PERDER O UPLOAD ANTECIPADO
# Se cropped_video_url estiver vazio, tentaremos pegar do banco
if not cropped_video_url:
cropped_video_url = record.get("crop_content_url")
if result_json:
result_data = result_json[0] if isinstance(result_json[0], dict) else {}
title_text = result_data.get("title", "")
# Se for vídeo, chamar a API de video export
if 'image' not in content_type and title_text:
# Título de vídeo sempre começa com maiúscula
title_text = title_text[0].upper() + title_text[1:] if title_text else title_text
try:
# Usar o vídeo cortado se disponível, senão o original
video_for_export = cropped_video_path if cropped_video_path and os.path.exists(cropped_video_path) else temp_file.name
if screenshot_task:
screenshot_url, video_duration, screenshot_temp_path = await screenshot_task
screenshot_path = screenshot_temp_path
else:
screenshot_url, video_duration, screenshot_temp_path = None, 10.0, None
if screenshot_url:
# 3. Upload do vídeo cortado para recurve-save (Se ainda não foi feito)
if (video_for_export != temp_file.name) and not cropped_video_url:
print(f"☁️ [{time.time()-t_start:.1f}s] Enviando vídeo cortado para recurve-save (fallback)...")
def upload_crop_acct():
with open(video_for_export, 'rb') as vf:
vid_up_resp = requests.post(
"https://habulaj-recurve-save.hf.space/upload",
files={'files': ('cropped_video.mp4', vf, 'video/mp4')},
data={'long_duration': 'yes'},
timeout=120
)
vid_up_resp.raise_for_status()
return vid_up_resp.json().get("url", "")
cropped_video_url = await asyncio.to_thread(upload_crop_acct)
# URL do vídeo para o export: cortado se disponível, senão original
export_video_url = cropped_video_url if cropped_video_url else video_url
print(f"✅ [{time.time()-t_start:.1f}s] Vídeo para export: {export_video_url}")
# 4. Obter dimensões do vídeo para cálculo horizontal/vertical
print(f"📏 [{time.time()-t_start:.1f}s] Preparando export...")
import cv2
cap = cv2.VideoCapture(video_for_export)
crop_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) if cap.isOpened() else 1080
crop_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) if cap.isOpened() else 1920
cap.release()
canvas_width = 1080
image_height = 616
video_dest_height = 1920 - image_height # 1304px
video_template = "horizontal" if crop_w > crop_h else "vertical"
print(f"📐 Dimensões do vídeo: {crop_w}x{crop_h} | Template: {video_template}")
# Para vídeo vertical: calcular video_y para centralizar o crop verticalmente
# O videoexport escala o vídeo para largura=1080 mantendo proporção,
# depois faz crop de cima. Precisamos passar o offset Y correto.
if video_template == "vertical":
# Altura escalada quando width=1080
if crop_w > 0:
scaled_h = int(crop_h * canvas_width / crop_w)
else:
scaled_h = crop_h
video_x = 0
video_y = max(0, (scaled_h - video_dest_height) // 2)
else:
# Horizontal: centralizado horizontalmente, sem offset vertical relevante
video_x = 0
video_y = 0
print(f" → video_y (crop offset): {video_y}px")
# 5. Gerar legendas se necessário
needs_legenda = result_data.get("legenda", False)
srt_for_export = None
if needs_legenda and record.get("translate", True):
print(f"🎙️ [{time.time()-t_start:.1f}s] Resolvendo legendas (Groq task + Gemini translate)...")
try:
if groq_task:
srt_raw = await groq_task
else:
srt_raw = None
if srt_raw and srt_raw.strip():
# Tradução via Gemini (mesmo prompt do /subtitle)
translate_prompt = f"""IDIOMA: A legenda traduzida DEVE ser inteiramente em PORTUGUÊS DO BRASIL (pt-BR). Independente do idioma original do vídeo.
Traduza essa legenda pro português do Brasil, corrija qualquer erro de formatação, pontuação e mantenha timestamps e os textos nos seus respectivos blocos de legenda.
Você DEVE se basear estritamente na legenda original fornecida. NUNCA crie legendas novas e NUNCA adicione ou verifique diálogos no áudio que não estejam presentes na legenda original. Apenas traduza.
Deve traduzir exatamente o texto da legenda observando o contexto, não é pra migrar, por exemplo, textos de um bloco de legenda pra outro. Deve traduzir exatamente o texto de cada bloco de legenda, manter sempre as palavras, nunca retirar.
Mande o SRT completo, sem textos adicionais na resposta, apenas o SRT traduzido. A legenda acima é uma base gerada pelo Whisper que precisa ser limpa e traduzida, não o resultado final.
A legenda deve ser totalmente traduzida corretamente analisando o contexto e a entonação de falar. Adapte gírias e qualquer coisa do tipo. Não deve ser literal a tradução, deve se adaptar.
MÚSICA E LETRAS:
- NUNCA LEGENDE MÚSICAS OU CANÇÕES.
- Se houver música de fundo ou pessoas cantando uma música, IGNORE COMPLETAMENTE e não inclua na legenda.
- VOCÊ DEVE LEGENDAR APENAS DIÁLOGOS E FALAS REAIS.
PALAVRÕES E CENSURA:
- Você DEVE censurar palavras de baixo calão e palavrões pesados utilizando asteriscos.
- Substitua a maior parte da palavra censurada e mantenha apenas as primeiras letras.
- Exemplo: "filha da puta" se torna "filha da pu**", "caralho" se torna "caral**", "merda" se torna "merd*", "foda" se torna "fod*".
EXTREMAMENTE IMPORTANTE: NUNCA legende músicas (quando detectar que é uma música, não legende), apenas diálogos falados. Nunca altere o timing das legendas, deve ser exatamente igual ao original de referência. Nunca legende ações também, exemplo: [Música alta], [Música de encerramento], etc. Deve ser apenas, unicamente, diálogo humano.
EXEMPLO:
(Original): 1
00:00:01,000 --> 00:00:04,000
hey what are you doing here i thought you left already
2
00:00:04,500 --> 00:00:07,200
yeah i was going to but then i realized i forgot my keys
3
00:00:07,900 --> 00:00:10,500
you always forget something man this is crazy
4
00:00:11,000 --> 00:00:14,000
relax it's not a big deal stop acting like that
5
00:00:14,500 --> 00:00:17,800
i am not acting you said you would be on time
6
00:00:18,000 --> 00:00:21,500
okay okay i'm sorry can we just go now
7
00:00:22,000 --> 00:00:25,000
fine but if we are late again you are a son of a bitch
(Traduzido, como você deveria traduzir): 1
00:00:01,000 --> 00:00:04,000
Ué, o que você tá fazendo aqui? Não era pra você já ter ido embora?
2
00:00:04,500 --> 00:00:07,200
Eu ia, mas aí percebi que esqueci minhas chaves.
3
00:00:07,900 --> 00:00:10,500
Cara, você sempre esquece alguma coisa, isso é surreal!
4
00:00:11,000 --> 00:00:14,000
Ah, relaxa! Não é o fim do mundo, para de drama.
5
00:00:14,500 --> 00:00:17,800
Não é drama! Você falou que ia chegar no horário!
6
00:00:18,000 --> 00:00:21,500
Tá, tá... foi mal. Bora logo?
7
00:00:22,000 --> 00:00:25,000
Tá bom, mas se a gente se atrasar de novo, você é um filha da pu**!
LEGENDA ORIGINAL:
{srt_raw}"""
translate_model = get_gemini_model("flash")
translate_resp = await client.generate_content(translate_prompt, model=translate_model)
translated_srt = translate_resp.text.strip()
# Limpar bloco de código se o Gemini retornar com ```
if "```" in translated_srt:
parts = translated_srt.split("```")
for part in parts:
clean = part.strip()
if clean.startswith("srt"): clean = clean[3:].strip()
if re.match(r"\d+\s*\n\d{2}:", clean):
translated_srt = clean
break
if translated_srt:
srt_for_export = translated_srt
print(f"✅ Legendas geradas ({len(srt_for_export)} chars)")
else:
print("⚠️ Gemini retornou SRT vazio na tradução")
else:
print("⚠️ Groq não retornou transcrição válida")
except Exception as sub_e:
print(f"⚠️ Erro ao gerar legendas: {sub_e}")
# 6. Montar title_url
import urllib.parse
title_params = urllib.parse.urlencode({
"text": title_text,
"version": account
})
title_url = f"https://habulaj-recurve-api-img.hf.space/cover/title?{title_params}&image_url={screenshot_url}"
if result_json and isinstance(result_json[0], dict):
result_json[0]["title_url"] = title_url
# 7. Chamar API de video export
print(f"🎬 Chamando video export API...")
export_payload = {
"video_url": export_video_url,
"title_url": title_url,
"image_height": image_height,
"cut_start": 0,
"cut_end": video_duration,
"video_x": video_x,
"video_y": video_y,
"video_width": crop_w,
"video_height": crop_h,
"video_template": video_template
}
if srt_for_export:
export_payload["subtitles"] = srt_for_export
print(f"📝 Legendas incluídas no export")
export_resp = requests.post(
"https://habulaj-recurve-videos-export.hf.space/video/export",
json=export_payload,
timeout=600
)
# Log do payload para depuração
print(f"📡 Export Payload URL: {export_payload.get('video_url')}")
if not export_resp.ok:
raise Exception(f"API de video export falhou com status {export_resp.status_code}: {export_resp.text}")
try:
export_data = export_resp.json()
except Exception as json_e:
print(f"⚠️ Resposta da API não é JSON: {export_resp.text[:500]}")
raise Exception(f"API de video export retornou resposta inválida (não JSON). Status: {export_resp.status_code}. Resposta: {export_resp.text[:200]}")
final_content_url = export_data.get("video_url")
if not final_content_url:
raise Exception(f"API de video export não retornou a URL do vídeo final. Resposta: {export_data}")
print(f"✅ Vídeo exportado: {final_content_url}")
else:
raise Exception(f"Falha ao obter URL do screenshot original. video_for_export={video_for_export}, file_exists={os.path.exists(video_for_export) if video_for_export else 'N/A'}")
except Exception as ve:
print(f"⚠️ Erro crítico no video export: {ve}")
raise Exception(f"Falha na etapa de video export: {ve}")
# Se for imagem e result_type == meme
elif 'image' in content_type and title_text and result_data.get("result_type") == "meme":
try:
contains_image = record.get("contains_image", False)
img_for_meme = cropped_file_path if cropped_file_path and os.path.exists(cropped_file_path) else temp_file.name
# Nano Banana Pro: só roda se tiver imagem de apoio E precisar de correção
image_needs_correction = record.get("image_needs_correction", False)
if contains_image and image_needs_correction:
print("🍌 Chamando Nano Banana Pro (Gemini) para limpar a imagem...")
clean_prompt = "Anexei uma imagem que pode ser um post de rede social, um meme, um screenshot ou qualquer composição visual que mistura texto com imagem. Sua única tarefa é limpar essa imagem, removendo tudo que não faz parte do conteúdo visual original. Remova completamente qualquer texto sobreposto, seja título, legenda, frase, username, arroba, logo ou qualquer marcação de outras páginas ou plataformas. Onde o texto estiver sobreposto diretamente na imagem, remova-o e reconstrua o fundo de forma coerente com o estilo visual ao redor. Preserve absolutamente tudo da imagem original: cores, iluminação, estilo artístico, traços, texturas, proporções, enquadramento, clima e contexto visual. Não altere, não melhore, não filtre e não modifique nada além do que for necessário para a remoção dos textos e marcas. Retorne apenas a imagem limpa, sem nenhum texto, sem nenhuma explicação, sem nenhum comentário. Só a imagem, mantendo a mesma proporção e tamanho."
nano_model = get_gemini_model("pro")
clean_res = await client.generate_content(clean_prompt, files=[img_for_meme], model=nano_model)
if clean_res.images:
cleaned_path = await clean_res.images[0].save(path="static/processed", filename=f"cleaned_{record_id}")
if cleaned_path and os.path.exists(cleaned_path):
img_for_meme = cleaned_path
print(f"✨ Imagem limpa salva em: {img_for_meme}")
try:
from gemini_watermark_remover import process_image
unwatermarked_path = cleaned_path.replace(".jpg", "_nowm.jpg").replace(".png", "_nowm.png")
if "_nowm" not in unwatermarked_path: unwatermarked_path += "_nowm.jpg"
success = process_image(input_path=cleaned_path, output_path=unwatermarked_path, remove=True, auto_detect=True)
if success and os.path.exists(unwatermarked_path):
img_for_meme = unwatermarked_path
print(f"✨ Marca d'água do Gemini removida com sucesso. Nova imagem salva em: {img_for_meme}")
else:
print("⚠️ Nenhuma marca d'água do Gemini encontrada ou erro silencioso. Mantendo imagem.")
except Exception as wm_e:
print(f"⚠️ Erro ao executar a limpeza da marca d'água: {wm_e}")
else:
print("⚠️ Gemini não retornou imagem limpa. Usando a original.")
import urllib.parse
if contains_image:
# Tem imagem de apoio: faz upload e inclui image_url no meme
print("📸 Enviando imagem base do meme para recurve-save...")
with open(img_for_meme, 'rb') as img_f:
upload_resp = requests.post(
"https://habulaj-recurve-save.hf.space/upload",
files={'files': ('meme_base.jpg', img_f, 'image/jpeg')},
timeout=60
)
upload_resp.raise_for_status()
uploaded_img_url = upload_resp.json().get("url", "")
if uploaded_img_url:
meme_params = urllib.parse.urlencode({
"text": title_text
})
final_content_url = f"https://habulaj-recurve-api-img.hf.space/meme?{meme_params}&image_url={uploaded_img_url}"
if result_json and isinstance(result_json[0], dict):
result_json[0]["title_url"] = final_content_url
print(f"✅ Meme API URL gerada (com imagem): {final_content_url}")
else:
raise Exception("Falha ao receber a URL da imagem upada via recurve-save.")
else:
# Só texto, sem imagem de apoio: manda apenas o text
meme_params = urllib.parse.urlencode({"text": title_text})
final_content_url = f"https://habulaj-recurve-api-img.hf.space/meme?{meme_params}"
if result_json and isinstance(result_json[0], dict):
result_json[0]["title_url"] = final_content_url
print(f"✅ Meme API URL gerada (só texto): {final_content_url}")
except Exception as e:
print(f"⚠️ Erro crítico ao gerar URL do meme: {e}")
raise Exception(f"Falha na etapa de geração da imagem (meme): {e}")
# Add generated subtitles to the result JSON for Supabase
if result_json and srt_for_export:
if isinstance(result_json[0], dict):
result_json[0]["subtitle_srt"] = srt_for_export
# Atualizar no Supabase
update_url = f"{supabase_url}/rest/v1/posts?id=eq.{record_id}"
patch_payload = {
"result": result_json
}
if final_content_url:
patch_payload["final_content_url"] = final_content_url
if cropped_video_url:
patch_payload["crop_content_url"] = cropped_video_url
res_patch = requests.patch(update_url, headers=headers, json=patch_payload, timeout=10)
if not res_patch.ok:
print(f"⚠️ Erro ao atualizar Supabase: {res_patch.text}")
raise HTTPException(status_code=500, detail=f"Erro ao atualizar record no Supabase: {res_patch.text}")
response_data = {
"success": True,
"record_id": record_id,
"result": result_json
}
if final_content_url:
response_data["final_content_url"] = final_content_url
if srt_for_export:
response_data["subtitle_srt"] = srt_for_export
# 10. Notificação de conclusão: Sistema (ID 0) avisando que mídia está pronta
try:
import urllib.parse as _up
_r0 = result_json[0] if result_json else {}
legenda_done = _r0.get("description", "")
# ID 0 (sistema): marcou como finalizada + legenda completa + link
final_msg_add = f"\n\n🔗 **Vídeo Final:** {final_content_url}" if final_content_url else ""
_sys_end_msg = f"✅ **{agent_name}** finalizou a renderização para o post #{record_id}.\n\n💬 **Legenda:** {legenda_done}{final_msg_add}"
_sys_end_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + _up.urlencode({"mensagem": _sys_end_msg, "id": system_discord_id})
requests.get("https://proxy.onrecurve.com/", params={"quest": _sys_end_url}, timeout=5)
except Exception as _e:
print(f"⚠️ Erro ao enviar notificação de conclusão para o Discord: {_e}")
publish_res = await safe_call_publish(account)
response_data["publish_result"] = publish_res
return response_data
except Exception as e:
import traceback
err_msg = str(e)
print(f"⚠️ Erro no processamento: {err_msg}")
try:
import urllib.parse as _up
sys_err_msg = f"<@1331348103806189675> 🚨 **ERRO CRÍTICO** ao processar post #{record_id if record_id else 'desconhecido'}:\n\n`{err_msg}`\n\nNão foi possível concluir a solicitação."
sys_err_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + _up.urlencode({"mensagem": sys_err_msg, "id": system_discord_id})
requests.get("https://proxy.onrecurve.com/", params={"quest": sys_err_url}, timeout=5)
except Exception as dc_e:
print(f"⚠️ Erro ao enviar Discord de falha: {dc_e}")
if record_id:
# A pedido do usuário, não vamos salvar falso result de falha no Supabase
# Só salvará quando de fato gerar com sucesso o final_content_url e result.
pass
publish_res = await safe_call_publish(account)
return JSONResponse(status_code=500, content={"error": f"Erro interno: {err_msg}", "publish_result": publish_res})
finally:
if temp_file and os.path.exists(temp_file.name): os.unlink(temp_file.name)
if cropped_file_path and os.path.exists(cropped_file_path): os.unlink(cropped_file_path)
if cropped_video_path and os.path.exists(cropped_video_path): os.unlink(cropped_video_path)
if screenshot_path and os.path.exists(screenshot_path): os.unlink(screenshot_path)
async def safe_call_process(account: str):
try:
res = await process_account_endpoint(account)
if isinstance(res, JSONResponse):
import json
return json.loads(res.body.decode('utf-8'))
return res
except HTTPException as e:
return {"error": e.detail, "status_code": e.status_code}
except Exception as e:
return {"error": str(e), "status_code": 500}
async def safe_call_publish(account: str):
try:
res = await publish_account_endpoint(account)
if isinstance(res, JSONResponse):
import json
return json.loads(res.body.decode('utf-8'))
return res
except HTTPException as e:
return {"error": e.detail, "status_code": e.status_code}
except Exception as e:
return {"error": str(e), "status_code": 500}
@app.api_route("/filter/{account}", methods=["GET", "POST"])
async def filter_account_endpoint(account: str, background_tasks: BackgroundTasks):
background_tasks.add_task(run_filter_account, account, background_tasks)
return {"status": "ok", "message": "Solicitação enviada com sucesso! O processo continuará em background."}
async def run_filter_account(account: str, background_tasks: BackgroundTasks = None):
if not client:
raise HTTPException(status_code=500, detail="Gemini client is not initialized")
temp_file = None
record_id = None
try:
supabase_url = os.getenv("SUPABASE_URL", "").rstrip("/")
supabase_key = os.getenv("SUPABASE_KEY", "")
if not supabase_url or not supabase_key:
raise HTTPException(status_code=500, detail="Credenciais do Supabase não configuradas no ambiente.")
headers = {
"apikey": supabase_key,
"Authorization": f"Bearer {supabase_key}",
"Content-Type": "application/json"
}
from agent_config import AGENTS
if account not in AGENTS:
raise HTTPException(status_code=400, detail="Conta não configurada.")
agent_conf = AGENTS[account]["filter"]
agent_name = agent_conf["name"]
discord_id = agent_conf["discord_id"]
system_discord_id = AGENTS[account].get("system_discord_id", 0)
# Buscar 1 post pendente para filtro para essa conta
select_url = f"{supabase_url}/rest/v1/posts?select=*&account_target=eq.{account}&filter_message=is.null&user_created=eq.false&limit=1"
res_get = requests.get(select_url, headers=headers, timeout=10)
if not res_get.ok:
raise HTTPException(status_code=500, detail=f"Erro ao ler posts: {res_get.text}")
records = res_get.json()
if not records:
process_res = await safe_call_process(account)
return {"status": "ok", "message": "Nenhuma postagem pendente para ser filtrada.", "next_steps": {"process": process_res}}
record = records[0]
record_id = record.get("id")
# Inicializa o logger detalhado assim que temos o ID do registro
logger = ProcessLogger(agent_name, record_id)
url_to_download = record.get("ig_post_url")
context_text = record.get("ig_caption", "")
if not url_to_download:
logger.log("❌ Falha: ig_post_url inválida.")
raise HTTPException(status_code=400, detail=f"Registro ID {record_id} falhou: ig_post_url inválida.")
import urllib.parse
try:
logger.log(f"📢 Notificando Discord sobre início do filtro...")
sys_msg = f"🏃‍♀️ **{agent_name}** começou a filtrar uma postagem...\n\n📎 **Mídia:** {url_to_download}"
sys_target_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + urllib.parse.urlencode({
"mensagem": sys_msg,
"id": system_discord_id
})
requests.get("https://proxy.onrecurve.com/", params={"quest": sys_target_url}, timeout=5)
except Exception as e:
logger.log(f"⚠️ Erro ao enviar mensagem de sistema para o Discord: {e}")
# Buscar duplicados para verificação rigorosa (últimos 50 posts publicados)
logger.log("🔍 Buscando as últimas postagens para evitar duplicação no Gemini...")
dups_url = f"{supabase_url}/rest/v1/posts?select=result&account_target=eq.{account}&published=eq.true&result=not.is.null&limit=50&order=created_at.desc"
res_dups = requests.get(dups_url, headers=headers, timeout=10)
recent_posts_text = ""
if res_dups.ok:
dups = res_dups.json()
dup_list = []
for d in dups:
res = d.get("result")
if res and isinstance(res, list) and len(res) > 0:
r0 = res[0] if isinstance(res[0], dict) else {}
t = r0.get("title", "")
desc = r0.get("description", "")
if t or desc:
dup_list.append(f"Título: {t}\nDescrição: {desc}")
if dup_list:
recent_posts_text = "\n\n=== ATENÇÃO: VERIFICAÇÃO RIGOROSA DE DUPLICAÇÃO ===\nVerifique rigorosamente se o conteúdo atual (vídeo/imagem e contexto) relata ou mostra EXATAMENTE a mesma situação de alguma dessas postagens recentes que já fizemos. Se for repetido e já tivermos publicado, REJEITE IMEDIATAMENTE! Histórico recente de postagens:\n"
for i, text in enumerate(dup_list, 1):
recent_posts_text += f"\nPost {i}:\n{text}\n"
logger.log(f"📥 Baixando mídia: {url_to_download}")
try:
response = download_file_with_retry(url_to_download, timeout=600, logger=logger)
except HTTPException as he:
logger.log(f"🚫 Falha ao baixar a mídia no filtro ({he.detail}).")
err_msg = str(he.detail)
reject_msg = f"Essa postagem possui um link de mídia quebrado ou expirado (Provavelmente erro 403 Forbidden). Como não consegui baixar, estou rejeitando no filtro automaticamente. ❌"
try:
import urllib.parse as _up
_reject_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + _up.urlencode({"mensagem": reject_msg, "id": discord_id})
requests.get("https://proxy.onrecurve.com/", params={"quest": _reject_url}, timeout=5)
except Exception as _e_dc:
logger.log(f"⚠️ Erro ao enviar Discord de rejeição automática de filtro: {_e_dc}")
requests.patch(f"{supabase_url}/rest/v1/posts?id=eq.{record_id}", headers=headers, json={
"approved_filter": False,
"published": False,
"filter_message": f"Rejeitado automaticamente pelo Filtro: Link de mídia quebrado ou bloqueado ({err_msg}).",
"result": {"error": f"Erro de mídia no filtro: {err_msg}"},
"contains_image": False
})
return {"status": "ok", "message": "Postagem rejeitada no filtro por falha no download.", "next_steps": {}}
content_type = response.headers.get('content-type', '').lower()
content_length = response.headers.get('content-length', 'unknown')
logger.log(f"📄 Header recebido: type={content_type}, size={content_length} bytes")
if 'image' in content_type:
if 'png' in content_type: ext = '.png'
elif 'webp' in content_type: ext = '.webp'
else: ext = '.jpg'
else:
ext = '.webm' if 'webm' in content_type else '.mp4'
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
for chunk in response.iter_content(chunk_size=1024*1024):
if chunk: temp_file.write(chunk)
media_path_to_analyze = temp_file.name
file_size = os.path.getsize(media_path_to_analyze)
logger.log(f"💾 Mídia salva localmente: {media_path_to_analyze} ({file_size / (1024*1024):.2f} MB)")
# 2.5 Verificação de Duração (Limite: 1 minuto)
if 'image' not in content_type:
try:
probe = subprocess.run(
["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", media_path_to_analyze],
capture_output=True, text=True, timeout=15
)
duration = float(probe.stdout.strip()) if probe.returncode == 0 and probe.stdout.strip() else 0
logger.log(f"⏱️ Duração do vídeo: {duration:.2f}s")
if duration > 60:
logger.log(f"🚫 Vídeo reprovado no filtro: {duration:.2f}s excede o limite de 1 minuto.")
filter_msg = f"Analisei esse vídeo e notei que ele tem {duration:.2f}s. Como o meu limite é de no máximo 1 minuto (60 segundos) para garantir a qualidade, vou ter que reprovar esse aqui. ❌"
try:
import urllib.parse as _up
_reject_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + _up.urlencode({"mensagem": filter_msg, "id": discord_id})
requests.get("https://proxy.onrecurve.com/", params={"quest": _reject_url}, timeout=5)
except Exception as _e_dc:
logger.log(f"⚠️ Erro ao enviar Discord de rejeição por duração: {_e_dc}")
requests.patch(f"{supabase_url}/rest/v1/posts?id=eq.{record_id}", headers=headers, json={
"approved_filter": False,
"published": False,
"filter_message": f"Rejeitado automaticamente: Duração de {duration:.2f}s excede o limite de 60s.",
"result": {"error": f"O vídeo é muito longo ({duration:.2f}s). O limite é 60s."},
"contains_image": False
})
return {"status": "ok", "message": "Postagem rejeitada por duração excessiva.", "next_steps": {}}
except Exception as e:
logger.log(f"⚠️ Erro ao verificar duração do vídeo: {e}")
cropped_file_path = None
crop_content_url = None
# 3. Crop (Filtro Inteligente)
if 'image' in content_type:
logger.log("✂️ Processando imagem: detectando região ativa...")
try:
cropped_file_path = detect_and_crop_image(media_path_to_analyze)
if cropped_file_path:
logger.log(f"✅ Imagem cortada com sucesso em: {cropped_file_path}")
except Exception as e:
logger.log(f"⚠️ Erro ao cortar imagem: {e}")
else:
logger.log("✂️ Processando vídeo: detectando movimento e bordas...")
try:
# Criar arquivo temporário para o vídeo cortado (Passo 1: Motion Crop)
cropped_video_tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.mp4').name
cropped_file_path = cropped_video_tmp # Track for finally cleanup
# Desativamos o text_cut inicial para fazer a análise do Gemini antes
crop_status = detect_and_crop_video(media_path_to_analyze, cropped_video_tmp, text_cut=False)
if crop_status == "success" and os.path.exists(cropped_video_tmp):
# Passo 2: Verificação via Gemini para saber se deve rodar OCR
logger.log("🧠 Verificando presença de textos externos via Gemini...")
text_check_model = get_gemini_model("flash")
text_check_prompt = """Analise o vídeo anexado e determine se há textos de edição externa (overlays de terceiros) adicionados após a produção original ou profissional do conteúdo.
Retorne um JSON no formato: {"has_texts": true/false}
Regras de Classificação:
- Considere como FALSE (Não marcar como texto de edição):
- Gráficos de Transmissão: Nomes de indicados, categorias de premiação, placares esportivos ou "lower thirds" que fazem parte da transmissão oficial da TV/Evento.
- Créditos Artísticos/Branding Profissional: Logotipos de marcas (ex: "Dior") ou créditos integrados que fazem parte da peça audiovisual original ou de um "lookbook" profissional.
- Textos de Ambiente: Placas, telões no fundo do palco ou escritos em roupas.
- Estética Profissional: Qualquer texto que tenha sido claramente renderizado como parte da master original do vídeo.
- Considere como TRUE (Marcar como texto de edição):
- Legendas de Redes Sociais: Captions explicativas ou legendas automáticas adicionadas para visualização em redes sociais.
- Textos de Engajamento: Frases como "Assista até o final", "O que você achou?", emojis flutuantes ou textos genéricos de meme.
- Marcas d'água de editores externos: Textos que identificam perfis de usuários ou editores que não são os produtores originais do evento.
Objetivo: O foco é detectar se o vídeo foi "re-editado" com textos informativos/estáticos típicos de redes sociais. Se o texto parece vir da produção original (Oscar, marcas de luxo, TV), retorne false. Responda apenas com o JSON, sem explicações."""
try:
text_resp = await client.generate_content(text_check_prompt, files=[cropped_video_tmp], model=text_check_model)
text_data = extract_json_from_text(text_resp.text)
has_texts = text_data.get("has_texts", False) if isinstance(text_data, dict) else False
if has_texts:
logger.log("🔍 Gemini detectou textos de edição externa. Iniciando OCR...")
# Passo 3: OCR Crop (Apenas se o Gemini confirmou)
ocr_video_tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.mp4').name
text_crop_status = detect_and_crop_text(cropped_video_tmp, ocr_video_tmp)
if text_crop_status == "aborted_area_too_small":
logger.log("🚫 Crop abortado: área de texto no centro (segurança). Reprovando filtro.")
# Notificação do Agente no Discord
try:
import urllib.parse as _up
reject_msg = f"Analisei esse vídeo aqui e notei que o texto dele está bem no centro. Se eu fizesse o corte agora, ia acabar cortando partes importantes da mensagem. Por isso, decidi reprovar ele automaticamente para manter a qualidade! ❌"
_reject_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + _up.urlencode({"mensagem": reject_msg, "id": discord_id})
requests.get("https://proxy.onrecurve.com/", params={"quest": _reject_url}, timeout=5)
except Exception as _e_dc:
logger.log(f"⚠️ Erro ao enviar Discord de rejeição automática (crop): {_e_dc}")
requests.patch(f"{supabase_url}/rest/v1/posts?id=eq.{record_id}", headers=headers, json={
"approved_filter": False,
"filter_message": "Rejeitado automaticamente: Região útil de texto insuficiente para crop seguro (texto centralizado no vídeo).",
"result": {"error": "Rejeitado automaticamente: Região útil de texto insuficiente para crop seguro (texto centralizado). Tente outro vídeo."},
"contains_image": False
})
return {"status": "ok", "message": "Postagem rejeitada por crop insuficiente.", "next_steps": {}}
elif text_crop_status == "success" and os.path.exists(ocr_video_tmp):
# Reemplazamos el motion crop original por el OCR crop
if os.path.exists(cropped_video_tmp): os.unlink(cropped_video_tmp)
cropped_video_tmp = ocr_video_tmp
cropped_file_path = ocr_video_tmp # Update tracking
logger.log(f"✅ Vídeo cortado via OCR com sucesso em: {cropped_video_tmp}")
else:
if os.path.exists(ocr_video_tmp): os.unlink(ocr_video_tmp)
logger.log("⚠️ Crop de OCR ignorado ou falhou, mantendo apenas motion crop.")
else:
logger.log("✅ Gemini NÃO detectou textos de social media. Pulando OCR.")
except Exception as ge:
logger.log(f"⚠️ Erro na análise Gemini de textos: {ge}. Seguindo apenas com motion crop.")
cropped_file_path = cropped_video_tmp
logger.log(f"✅ Vídeo (motion) consolidado em: {cropped_file_path}")
else:
logger.log("⚠️ Crop de vídeo não gerou novo arquivo ou foi ignorado.")
if os.path.exists(cropped_video_tmp): os.unlink(cropped_video_tmp)
except Exception as e:
logger.log(f"⚠️ Erro ao cortar vídeo: {e}")
# 4. Upload do crop (Se houver) para reuso no Processamento
if cropped_file_path and os.path.exists(cropped_file_path):
try:
logger.log("☁️ Enviando mídia cortada para recurve-save (reuso posterior)...")
is_vid = 'image' not in content_type
with open(cropped_file_path, 'rb') as cf:
files = {'files': (f"crop_{'vid' if is_vid else 'img'}{ext}", cf, content_type)}
data = {'long_duration': 'yes'} if is_vid else {}
up_resp = requests.post("https://habulaj-recurve-save.hf.space/upload", files=files, data=data, timeout=120)
up_resp.raise_for_status()
crop_content_url = up_resp.json().get("url", "")
logger.log(f"✅ Crop content URL obtida: {crop_content_url}")
except Exception as up_e:
logger.log(f"⚠️ Erro ao subir crop para recurve-save: {up_e}")
# Ordem de anexos para Gemini: [cortado, original]
files_to_send = []
if cropped_file_path and os.path.exists(cropped_file_path):
files_to_send.append(cropped_file_path)
files_to_send.append(media_path_to_analyze)
contexto_add = f"\n\nContexto Adicional / Legenda Original:\n{context_text}" if context_text else ""
prompt = agent_conf["get_prompt"](
date_str=time.strftime('%d/%m/%Y'),
contexto_add=contexto_add
)
if recent_posts_text:
prompt += recent_posts_text
# get_gemini_model("flash") chamará "Model.G_3_0_FLASH", que é o modelo Flash rápido.
# A demora de alguns segundos é comum porque a mídia precisa ser enviada e processada.
model_obj = get_gemini_model("flash")
logger.log(f"🧠 Enviando {len(files_to_send)} arquivos para Gemini (flash) para filtro de conteúdo...")
response_gemini = await client.generate_content(prompt, files=files_to_send, model=model_obj)
logger.log("✨ Resposta do Gemini recebida.")
filter_data = extract_json_from_text(response_gemini.text)
if filter_data is None:
return JSONResponse(content={"raw_content": response_gemini.text, "error": "Failed to parse JSON"}, status_code=200)
try:
import urllib.parse
# 1) Mensagem normal do filtro para o bot específico (id 1, 2 ou 3)
target_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + urllib.parse.urlencode({
"mensagem": filter_data.get("filter_message", ""),
"id": discord_id
})
requests.get(
"https://proxy.onrecurve.com/",
params={"quest": target_url},
timeout=5
)
# 2) Aviso final pro Painel de Sistema (ID 0)
is_approved = filter_data.get("approved_filter", False)
status_emoji = "✅" if is_approved else "❌"
status_text = "APROVOU" if is_approved else "REPROVOU"
sys_end_msg = f"{status_emoji} **{agent_name}** {status_text} a postagem para a próxima etapa."
sys_end_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + urllib.parse.urlencode({
"mensagem": sys_end_msg,
"id": system_discord_id
})
requests.get(
"https://proxy.onrecurve.com/",
params={"quest": sys_end_url},
timeout=5
)
except Exception as e:
print(f"⚠️ Erro ao enviar log para o Discord: {e}")
# Atualizar no Supabase
update_url = f"{supabase_url}/rest/v1/posts?id=eq.{record_id}"
patch_payload = {
"filter_message": filter_data.get("filter_message"),
"approved_filter": filter_data.get("approved_filter"),
"image_needs_correction": filter_data.get("image_needs_correction", False),
"contains_image": filter_data.get("contains_image", False)
}
if crop_content_url:
patch_payload["crop_content_url"] = crop_content_url
res_patch = requests.patch(update_url, headers=headers, json=patch_payload, timeout=10)
if not res_patch.ok:
print(f"⚠️ Erro ao atualizar Supabase: {res_patch.text}")
# Desacoplar processamento: não dar 'await' se tivermos background_tasks
if background_tasks:
background_tasks.add_task(safe_call_process, account)
process_res = {"status": "started", "message": "Processamento iniciado em background."}
else:
process_res = await safe_call_process(account)
return {
"success": True,
"record_id": record_id,
"filter_data": filter_data,
"next_steps": {"process": process_res}
}
except Exception as e:
import traceback
err_msg = str(e)
print(f"⚠️ Erro no filtro: {err_msg}")
try:
import urllib.parse as _up
sys_err_msg = f"<@1331348103806189675> 🚨 **ERRO CRÍTICO** ao filtrar post #{record_id if record_id else 'desconhecido'}:\n\n`{err_msg}`\n\nNão foi possível concluir a solicitação."
sys_err_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + _up.urlencode({"mensagem": sys_err_msg, "id": system_discord_id})
requests.get("https://proxy.onrecurve.com/", params={"quest": _sys_url}, timeout=5)
except Exception as dc_e:
print(f"⚠️ Erro ao enviar Discord de falha: {dc_e}")
if record_id:
# A pedido do usuário, não vamos salvar falso result de falha no Supabase
# Só salvará quando de fato o filtro não aprovar.
pass
# Mesmo em erro no filtro, tentamos rodar o process (talvez pra outros posts)
if background_tasks:
background_tasks.add_task(safe_call_process, account)
process_res = {"status": "started", "message": "Processamento iniciado em background."}
else:
process_res = await safe_call_process(account)
return JSONResponse(status_code=500, content={"error": f"Erro interno: {err_msg}", "next_steps": {"process": process_res}})
finally:
if temp_file and os.path.exists(temp_file.name): os.unlink(temp_file.name)
if 'cropped_file_path' in locals() and cropped_file_path and os.path.exists(cropped_file_path):
os.unlink(cropped_file_path)
@app.api_route("/publish/{account}", methods=["GET", "POST"])
async def publish_account_endpoint(account: str):
if not client:
raise HTTPException(status_code=500, detail="Gemini client is not initialized")
temp_file = None
record_id = None
try:
supabase_url = os.getenv("SUPABASE_URL", "").rstrip("/")
supabase_key = os.getenv("SUPABASE_KEY", "")
if not supabase_url or not supabase_key:
raise HTTPException(status_code=500, detail="Credenciais do Supabase não configuradas no ambiente.")
headers = {
"apikey": supabase_key,
"Authorization": f"Bearer {supabase_key}",
"Content-Type": "application/json"
}
from agent_config import AGENTS
if account not in AGENTS:
raise HTTPException(status_code=400, detail="Conta não configurada.")
agent_conf = AGENTS[account]["publish"]
agent_name = agent_conf["name"]
discord_id = agent_conf["discord_id"]
system_discord_id = AGENTS[account].get("system_discord_id", 0)
# Buscar 1 post pronto para publicação
select_url = f"{supabase_url}/rest/v1/posts?select=*&account_target=eq.{account}&result=not.is.null&final_content_url=not.is.null&published=eq.false&user_created=eq.false&or=(superior_needs_verification.is.null,superior_needs_verification.eq.false)&limit=1"
res_get = requests.get(select_url, headers=headers, timeout=10)
if not res_get.ok:
raise HTTPException(status_code=500, detail=f"Erro ao ler posts: {res_get.text}")
records = res_get.json()
if not records:
return {"status": "ok", "message": "Nenhuma postagem pendente para publicação."}
record = records[0]
record_id = record.get("id")
logger = ProcessLogger(agent_name, record_id)
final_content_url = record.get("final_content_url", "")
result_data = record.get("result", [])
filter_message = record.get("filter_message", "")
if not final_content_url:
logger.log("❌ Falha: final_content_url inválida.")
raise HTTPException(status_code=400, detail=f"Registro ID {record_id} falhou: final_content_url inválida.")
publish_message = record.get("publish_message")
if publish_message and not record.get("published", False):
logger.log(f"🔄 Post já possui publish_message. Tentando publicar direto (RETRY)...")
try:
r0 = result_data[0] if isinstance(result_data, list) and len(result_data) > 0 else {}
post_type = record.get("type", "")
if post_type == "video":
is_video = True
elif post_type == "image":
is_video = False
elif r0.get("result_type") == "meme":
is_video = False
else:
head_resp = requests.head(final_content_url, allow_redirects=True, timeout=15)
content_type = head_resp.headers.get('content-type', '').lower()
is_video = 'image' not in content_type
caption_text = r0.get("description", "")
publish_payload = {
"account": account,
"caption": caption_text
}
if is_video:
publish_payload["video_url"] = final_content_url
else:
publish_payload["image_urls"] = [final_content_url]
logger.log(f"🚀 Enviando post para API de publicação (RETRY)...")
import json
logger.log(f"📦 Payload enviado (RETRY): {json.dumps(publish_payload, indent=2, ensure_ascii=False)}")
pub_resp = requests.post(
"https://igpublish.onrecurve.com/",
json=publish_payload,
timeout=300
)
is_published = False
needs_verification = False
sys_end_msg = ""
if pub_resp.ok:
pub_json = pub_resp.json()
if pub_json.get("success"):
post_url = pub_json.get("instagram", {}).get("post_url", "[URL não extraída]")
sys_end_msg = f"✅ **{agent_name}** PUBLICOU a postagem #{record_id} com sucesso (RETRY)!\n\n🔗 Link: {post_url}"
is_published = True
else:
err_det = pub_json.get("error", pub_json.get("instagram", {}).get("error", "Erro desconhecido"))
sys_end_msg = f"⚠️ A publicação do post #{record_id} falhou novamente (RETRY):\n`{err_det}`"
else:
sys_end_msg = f"⚠️ API de publicação retornou status {pub_resp.status_code} no RETRY:\n`{pub_resp.text}`"
if sys_end_msg:
import urllib.parse
sys_end_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + urllib.parse.urlencode({
"mensagem": sys_end_msg,
"id": system_discord_id
})
requests.get("https://proxy.onrecurve.com/", params={"quest": sys_end_url}, timeout=5)
update_url = f"{supabase_url}/rest/v1/posts?id=eq.{record_id}"
patch_payload = {
"published": is_published,
"superior_needs_verification": needs_verification
}
requests.patch(update_url, headers=headers, json=patch_payload, timeout=10)
return {
"success": True,
"record_id": record_id,
"retry_published": is_published
}
except Exception as retry_e:
logger.log(f"⚠️ Erro no retry de publicação: {retry_e}")
raise
# Notificação de início no Discord
try:
import urllib.parse
sys_msg = f"📦 **{agent_name}** começou a revisar uma postagem para publicação...\n\n📎 **Conteúdo:** {final_content_url}"
sys_target_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + urllib.parse.urlencode({
"mensagem": sys_msg,
"id": system_discord_id
})
requests.get(
"https://proxy.onrecurve.com/",
params={"quest": sys_target_url},
timeout=5
)
except Exception as e:
logger.log(f"⚠️ Erro ao enviar mensagem de sistema para o Discord: {e}")
# Baixar o conteúdo final para enviar ao Gemini
logger.log(f"📥 Baixando conteúdo final para revisão: {final_content_url}")
response = download_file_with_retry(final_content_url, timeout=600, logger=logger)
content_type = response.headers.get('content-type', '').lower()
if 'image' in content_type:
if 'png' in content_type: ext = '.png'
elif 'webp' in content_type: ext = '.webp'
else: ext = '.jpg'
else:
ext = '.webm' if 'webm' in content_type else '.mp4'
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
for chunk in response.iter_content(chunk_size=1024*1024):
if chunk: temp_file.write(chunk)
temp_file.close()
# Montar contexto do que a Vicky produziu (sem as mensagens de raciocínio para evitar viés)
vicky_result = ""
if result_data and isinstance(result_data, list) and len(result_data) > 0:
r0 = result_data[0] if isinstance(result_data[0], dict) else {}
vicky_result = f"""
RESULTADO GERADO PARA A POSTAGEM (o texto que vai pro ar com o post):
- Título: {r0.get('title', 'N/A')}
- Descrição: {r0.get('description', 'N/A')}
- Legenda (subtítulos): {r0.get('legenda', False)}
- Tipo: {r0.get('result_type', 'N/A')}
"""
post_type = record.get("type", "")
if post_type == "video":
is_video = True
elif post_type == "image":
is_video = False
else:
is_video = 'image' not in content_type
prompt = agent_conf["get_prompt"](
date_str=time.strftime('%d/%m/%Y'),
vicky_result_add=vicky_result
)
model_obj = get_gemini_model("flash")
logger.log(f"🧠 Enviando para Gemini (flash) para revisão de publicação...")
response_gemini = await client.generate_content(prompt, files=[temp_file.name], model=model_obj)
logger.log("✨ Resposta do Gemini recebida.")
publish_data = extract_json_from_text(response_gemini.text)
if publish_data is None:
return JSONResponse(content={"raw_content": response_gemini.text, "error": "Failed to parse JSON"}, status_code=200)
# Enviar mensagens no Discord
try:
import urllib.parse
# 1) Mensagem da Amanda para o canal dela (ID 3)
target_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + urllib.parse.urlencode({
"mensagem": publish_data.get("publish_message", ""),
"id": discord_id
})
requests.get(
"https://proxy.onrecurve.com/",
params={"quest": target_url},
timeout=5
)
# 2) Processo de Publicação e Aviso final pro Painel de Sistema (ID 0)
is_published = publish_data.get("published", False)
needs_verification = publish_data.get("superior_needs_verification", False)
sys_end_msg = ""
if is_published:
# Tenta publicar de fato
try:
r0 = result_data[0] if isinstance(result_data, list) and len(result_data) > 0 else {}
caption_text = r0.get("description", "")
publish_payload = {
"account": account,
"caption": caption_text
}
if is_video:
publish_payload["video_url"] = final_content_url
else:
publish_payload["image_urls"] = [final_content_url]
print(f"🚀 Enviando post #{record_id} para API de publicação...")
import json
print(f"📦 Payload enviado: {json.dumps(publish_payload, indent=2, ensure_ascii=False)}")
pub_resp = requests.post(
"https://igpublish.onrecurve.com/",
json=publish_payload,
timeout=300
)
if pub_resp.ok:
pub_json = pub_resp.json()
if pub_json.get("success"):
post_url = pub_json.get("instagram", {}).get("post_url", "[URL não extraída]")
sys_end_msg = f"✅ **{agent_name}** APROVOU e PUBLICOU a postagem #{record_id}!\n\n🔗 Link: {post_url}"
else:
err_det = pub_json.get("error", pub_json.get("instagram", {}).get("error", "Erro desconhecido"))
sys_end_msg = f"⚠️ **{agent_name}** APROVOU a postagem #{record_id}, mas a publicação falhou:\n`{err_det}`"
is_published = False
needs_verification = True
else:
sys_end_msg = f"⚠️ **{agent_name}** APROVOU a postagem #{record_id}, mas a API de publicação retornou status {pub_resp.status_code}:\n`{pub_resp.text}`"
is_published = False
except Exception as pub_e:
sys_end_msg = f"⚠️ **{agent_name}** APROVOU a postagem #{record_id}, mas ocorreu uma exceção ao tentar publicar:\n`{str(pub_e)}`"
is_published = False
needs_verification = True
if not sys_end_msg:
if needs_verification:
sys_end_msg = f"@everyone\n\n🔍 **{agent_name}** SOLICITOU verificação de um superior da postagem #{record_id}."
else:
sys_end_msg = f"❌ **{agent_name}** REJEITOU a postagem #{record_id}."
sys_end_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + urllib.parse.urlencode({
"mensagem": sys_end_msg,
"id": system_discord_id
})
requests.get(
"https://proxy.onrecurve.com/",
params={"quest": sys_end_url},
timeout=5
)
except Exception as e:
print(f"⚠️ Erro ao enviar log para o Discord: {e}")
# Atualizar no Supabase
update_url = f"{supabase_url}/rest/v1/posts?id=eq.{record_id}"
patch_payload = {
"publish_message": publish_data.get("publish_message"),
"published": is_published,
"superior_needs_verification": needs_verification
}
res_patch = requests.patch(update_url, headers=headers, json=patch_payload, timeout=10)
if not res_patch.ok:
print(f"⚠️ Erro ao atualizar Supabase: {res_patch.text}")
return {
"success": True,
"record_id": record_id,
"publish_data": publish_data
}
except Exception as e:
import traceback
err_msg = str(e)
print(f"⚠️ Erro na publicação: {err_msg}")
try:
import urllib.parse as _up
sys_err_msg = f"<@1331348103806189675> 🚨 **ERRO CRÍTICO** na publicação do post #{record_id if record_id else 'desconhecido'}:\n\n`{err_msg}`\n\nNão foi possível concluir a solicitação."
sys_err_url = "https://discordmsg.arthurmribeiro51.workers.dev/?" + _up.urlencode({"mensagem": sys_err_msg, "id": system_discord_id})
requests.get("https://proxy.onrecurve.com/", params={"quest": sys_err_url}, timeout=5)
except Exception as dc_e:
print(f"⚠️ Erro ao enviar Discord de falha: {dc_e}")
if record_id:
try:
update_url = f"{supabase_url}/rest/v1/posts?id=eq.{record_id}"
patch_payload = {
"publish_message": None,
"published": False
}
requests.patch(update_url, headers=headers, json=patch_payload, timeout=10)
except Exception as sup_e:
print(f"⚠️ Erro ao atualizar falha no Supabase: {sup_e}")
return JSONResponse(status_code=500, content={"error": f"Erro interno: {err_msg}"})
finally:
if temp_file and os.path.exists(temp_file.name): os.unlink(temp_file.name)
# ==========================================
# GROQ ENDPOINTS / SUBTITLES
# ==========================================
GROQ_API_KEY = os.getenv("GROQ_API_KEY", "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")
try:
from srt_utils import (
process_audio_for_transcription,
shift_srt_timestamps,
seconds_to_srt_time,
srt_time_to_seconds,
groq_json_to_srt,
groq_words_to_srt,
groq_combined_to_srt
)
except ImportError:
# Caso o arquivo srt_utils.py não exista mais, cria mocks que evitam problemas
print("WARNING: srt_utils.py não foi encontrado. Algumas funções foram desabilitadas.")
def process_audio_for_transcription(fp, **kwargs): return fp
def shift_srt_timestamps(srt, time_start): return srt
def seconds_to_srt_time(s): return "00:00:00,000"
def groq_combined_to_srt(data, **kwargs): return ""
class GroqRequest(BaseModel):
url: str
language: Optional[str] = None
temperature: Optional[float] = 0.4
has_bg_music: Optional[bool] = False
time_start: Optional[float] = None
time_end: Optional[float] = None
async def get_groq_srt_base(url: str, language: Optional[str] = None, temperature: Optional[float] = 0.4, has_bg_music: bool = False, time_start: float = None, time_end: float = None, local_path: Optional[str] = None):
if local_path and os.path.exists(local_path):
filepath = local_path
filename = os.path.basename(filepath)
else:
response = download_file_with_retry(url)
ext = '.mp4' if 'video' in response.headers.get('content-type', '').lower() else '.mp3'
import uuid
filename = f"audio_{int(time.time())}_{uuid.uuid4().hex[:8]}{ext}"
filepath = os.path.join("static", filename)
with open(filepath, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk: f.write(chunk)
processed_audio_url = None
result = None
try:
processed_file_path = process_audio_for_transcription(filepath, has_bg_music=has_bg_music, time_start=time_start, time_end=time_end)
processed_filename = os.path.basename(processed_file_path)
processed_audio_url = f"/static/{processed_filename}"
with open(processed_file_path, "rb") as f:
files = [
("model", (None, "whisper-large-v3")),
("file", ("audio.mp3", f, "audio/mpeg")),
("temperature", (None, str(temperature))),
("response_format", (None, "verbose_json")),
("timestamp_granularities[]", (None, "segment")),
("timestamp_granularities[]", (None, "word"))
]
if language: files.append(("language", (None, language)))
for attempt in range(3):
try:
f.seek(0)
resp = requests.post("https://api.groq.com/openai/v1/audio/transcriptions", headers={"Authorization": f"Bearer {GROQ_API_KEY}"}, files=files, timeout=300)
if resp.status_code == 200:
result = resp.json()
break
elif attempt < 2 and ("context deadline" in resp.text.lower() or resp.status_code >= 500):
await asyncio.sleep(2 * (attempt + 1))
continue
raise HTTPException(status_code=resp.status_code, detail=f"Erro Groq: {resp.text}")
except Exception as e:
if attempt < 2:
await asyncio.sleep(2)
continue
raise HTTPException(status_code=500, detail=str(e))
finally:
if filepath and os.path.exists(filepath) and filepath != processed_file_path and filepath != local_path:
try: os.unlink(filepath)
except: pass
srt_base = groq_combined_to_srt(result, include_word_timings=False)
word_level = groq_words_to_srt(result)
return srt_base, srt_base, processed_audio_url, word_level
@app.post("/subtitle/groq")
async def generate_subtitle_groq(request: GroqRequest):
srt_filtered, srt_word, audio_url, _ = await get_groq_srt_base(
request.url, request.language, request.temperature, request.has_bg_music, request.time_start, request.time_end
)
if request.time_start and request.time_start > 0:
srt_filtered = shift_srt_timestamps(srt_filtered, request.time_start)
srt_word = shift_srt_timestamps(srt_word, request.time_start)
return JSONResponse(content={"srt": srt_filtered, "srt_word": srt_word})
class GeminiSubtitleRequest(BaseModel):
url: str
has_bg_music: Optional[bool] = False
translate: Optional[bool] = True
context: Optional[str] = "N/A"
model: Optional[str] = "flash"
time_start: Optional[float] = None
time_end: Optional[float] = None
@app.post("/subtitle")
async def generate_subtitle(request: GeminiSubtitleRequest):
if not client: raise HTTPException(status_code=500, detail="Gemini client is not initialized")
try:
srt_filtered, _, audio_url, word_level_text = await get_groq_srt_base(
request.url, None, 0.4, request.has_bg_music, request.time_start, request.time_end
)
# Se não for solicitado tradução/limpeza via Gemini, retorna o original direto
if not request.translate:
final_srt = srt_filtered
if request.time_start and request.time_start > 0:
final_srt = shift_srt_timestamps(final_srt, request.time_start)
return JSONResponse(content={
"srt": final_srt,
"original_srt": final_srt,
"srt_word_level": word_level_text,
"used_audio_processed": True,
"translated": False
})
filename = audio_url.split("/")[-1]
processed_audio_path = os.path.join("static", filename)
if not os.path.exists(processed_audio_path): processed_audio_path = os.path.join("static", "processed", filename)
# Contexto padrão solicitado caso não haja
default_context = "NUNCA legende músicas, apenas diálogos falados. Nunca altere o timing das legendas, deve ser exatamente igual ao original de referência."
if request.context and request.context.strip() != "N/A":
contexto_final = f"{default_context}\n\nCONTEXTO ADICIONAL DO USUÁRIO:\n{request.context.strip()}"
else:
contexto_final = default_context
prompt = f"""
IDIOMA: A legenda traduzida DEVE ser inteiramente em PORTUGUÊS DO BRASIL (pt-BR). Independente do idioma original do vídeo.
Traduza essa legenda pro português do Brasil, corrija qualquer erro de formatação, pontuação e mantenha timestamps e os textos nos seus respectivos blocos de legenda.
Você DEVE se basear estritamente na legenda original fornecida. NUNCA crie legendas novas e NUNCA adicione ou verifique diálogos no áudio que não estejam presentes na legenda original. Apenas traduza.
Deve traduzir exatamente o texto da legenda observando o contexto, não é pra migrar, por exemplo, textos de um bloco de legenda pra outro. Deve traduzir exatamente o texto de cada bloco de legenda, manter sempre as palavras, nunca retirar.
Mande o SRT completo, sem textos adicionais na resposta, apenas o SRT traduzido. A legenda acima é uma base gerada pelo Whisper que precisa ser limpa e traduzida, não o resultado final.
A legenda deve ser totalmente traduzida corretamente analisando o contexto e a entonação de falar. Adapte gírias e qualquer coisa do tipo. Não deve ser literal a tradução, deve se adaptar.
MÚSICA E LETRAS:
- NUNCA LEGENDE MÚSICAS OU CANÇÕES.
- Se houver música de fundo ou pessoas cantando uma música, IGNORE COMPLETAMENTE e não inclua na legenda.
- VOCÊ DEVE LEGENDAR APENAS DIÁLOGOS E FALAS REAIS.
PALAVRÕES E CENSURA:
- Você DEVE censurar palavras de baixo calão e palavrões pesados utilizando asteriscos.
- Substitua a maior parte da palavra censurada e mantenha apenas as primeiras letras.
- Exemplo: "filha da puta" se torna "filha da pu**", "caralho" se torna "caral**", "merda" se torna "merd*", "foda" se torna "fod*".
EXTREMAMENTE IMPORTANTE: NUNCA legende músicas (quando detectar que é uma música, não legende), apenas diálogos falados. Nunca altere o timing das legendas, deve ser exatamente igual ao original de referência. Nunca legende ações também, exemplo: [Música alta], [Música de encerramento], etc. Deve ser apenas, unicamente, diálogo humano.
EXEMPLO:
(Original): 1
00:00:01,000 --> 00:00:04,000
hey what are you doing here i thought you left already
2
00:00:04,500 --> 00:00:07,200
yeah i was going to but then i realized i forgot my keys
3
00:00:07,900 --> 00:00:10,500
you always forget something man this is crazy
4
00:00:11,000 --> 00:00:14,000
relax it's not a big deal stop acting like that
5
00:00:14,500 --> 00:00:17,800
i am not acting you said you would be on time
6
00:00:18,000 --> 00:00:21,500
okay okay i'm sorry can we just go now
7
00:00:22,000 --> 00:00:25,000
fine but if we are late again you are a son of a bitch
(Traduzido, como você deveria traduzir): 1
00:00:01,000 --> 00:00:04,000
Ué, o que você tá fazendo aqui? Não era pra você já ter ido embora?
2
00:00:04,500 --> 00:00:07,200
Eu ia, mas aí percebi que esqueci minhas chaves.
3
00:00:07,900 --> 00:00:10,500
Cara, você sempre esquece alguma coisa, isso é surreal!
4
00:00:11,000 --> 00:00:14,000
Ah, relaxa! Não é o fim do mundo, para de drama.
5
00:00:14,500 --> 00:00:17,800
Não é drama! Você falou que ia chegar no horário!
6
00:00:18,000 --> 00:00:21,500
Tá, tá... foi mal. Bora logo?
7
00:00:22,000 --> 00:00:25,000
Tá bom, mas se a gente se atrasar de novo, você é um filha da pu**!
INSTRUÇÕES/CONTEXTO DO USUÁRIO (OPCIONAL): {contexto_final}
--- LEGENDA BASE (WHISPER) ---
{srt_filtered}
"""
model_obj = get_gemini_model(request.model)
response_gemini = await client.generate_content(prompt, files=[processed_audio_path], model=model_obj)
cleaned_srt = clean_and_validate_srt(response_gemini.text)
if request.time_start and request.time_start > 0:
cleaned_srt = shift_srt_timestamps(cleaned_srt, request.time_start)
srt_filtered = shift_srt_timestamps(srt_filtered, request.time_start)
return JSONResponse(content={
"srt": cleaned_srt,
"original_srt": srt_filtered,
"srt_word_level": word_level_text,
"used_audio_processed": True
})
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))