subapi / main.py
habulaj's picture
Update main.py
43f7442 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
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
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 srt_time_to_seconds(timestamp):
try:
time_part, ms_part = timestamp.split(",")
h, m, s = map(int, time_part.split(":"))
ms = int(ms_part)
return h * 3600 + m * 60 + s + ms / 1000.0
except:
return 0.0
def seconds_to_srt_time(seconds):
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
ms = int((seconds % 1) * 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d},{ms:03d}"
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):
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
'Accept': '*/*'
}
for attempt in range(max_retries):
try:
if attempt > 0: 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 groq_json_to_srt(data):
srt_output = ""
segments = data.get("segments") or []
for i, segment in enumerate(segments, 1):
srt_output += f"{i}\n{seconds_to_srt_time(segment['start'])} --> {seconds_to_srt_time(segment['end'])}\n{segment['text'].strip()}\n\n"
return srt_output
def groq_words_to_text(data):
words = data.get("words") or []
if not words: return ""
return "\n".join([f" [{w.get('start', 0):.3f}s - {w.get('end', 0):.3f}s] {w.get('word', '').strip()}" for w in words])
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
@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
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"]
# 1. Chamar a API externa para obter informações do post
print(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:
raise Exception("API externa não retornou URL de mídia.")
print(f"✅ Mídia obtida: {video_url}")
# 2. Baixar mídia
print(f"📥 Baixando mídia: {video_url}")
response = download_file_with_retry(video_url, timeout=600)
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()
print(f"📥 [{time.time()-t_start:.1f}s] Download concluído.")
video_path_to_analyze = temp_file.name
files_to_send = [] # Ordem: [cropped, original]
# 3. Crop
if is_image:
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:
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, text_cut=request.text_cut)
if crop_status == "aborted_area_too_small":
print(f"🚫 [{time.time()-t_start:.1f}s] Crop abortado: área de texto no centro. Negando filtro.")
# 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:
print(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).",
"contains_image": False
})
return # Para o processamento aqui
if crop_status == "success" and os.path.exists(cropped_video_path):
files_to_send.append(cropped_video_path)
print(f"✅ [{time.time()-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()
exported_cropped_video_url = vid_upload_resp.json().get("url", "")
print(f"✅ [{time.time()-t_start:.1f}s] URL do vídeo cortado: {exported_cropped_video_url}")
else:
cropped_video_path = None
except Exception as e:
cropped_video_path = None
print(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
model_obj = get_gemini_model("flash")
print(f"🧠 [{time.time()-t_start:.1f}s] Enviando para Gemini (flash) [{agent_name}] process-url...")
response_gemini = await client.generate_content(prompt, files=files_to_send, model=model_obj)
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:
print("Failed to parse JSON")
return
result_json = titles_data if isinstance(titles_data, list) else [titles_data]
# 6. Video export
final_content_url = None
srt_for_export = None
exported_cropped_video_url = None
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:
video_for_export = (
cropped_video_path
if cropped_video_path and os.path.exists(cropped_video_path)
else temp_file.name
)
screenshot_path = tempfile.NamedTemporaryFile(delete=False, suffix=".jpg").name
duration_probe = subprocess.run(
["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", video_for_export],
capture_output=True, text=True,
)
video_duration = (
float(duration_probe.stdout.strip())
if duration_probe.returncode == 0 and duration_probe.stdout.strip()
else 10.0
)
middle_time = str(video_duration / 2)
ffmpeg_ss = subprocess.run(
["ffmpeg", "-y", "-i", video_for_export, "-ss", middle_time, "-frames:v", "1", screenshot_path],
capture_output=True, text=True,
)
if ffmpeg_ss.returncode == 0:
print("📸 Enviando screenshot para recurve-save...")
with open(screenshot_path, "rb") as ss_f:
upload_resp = requests.post(
"https://habulaj-recurve-save.hf.space/upload",
files={"files": ("screenshot.jpg", ss_f, "image/jpeg")},
timeout=60,
)
upload_resp.raise_for_status()
screenshot_url = upload_resp.json().get("url", "")
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...")
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()
exported_cropped_video_url = vid_upload_resp.json().get("url", "")
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:
print(f"🎙️ [{time.time()-t_start:.1f}s] Gerando legendas (Groq + Gemini)...")
try:
srt_raw, _, _, _ = await get_groq_srt_base(
export_video_url, language="en", temperature=0.4, has_bg_music=False
)
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. Se alguém estiver gritando, ESCREVA MAIÚSCULO! etc... 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")
print(f"✅ Vídeo exportado: {final_content_url}")
except Exception as ve:
print(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
except Exception as e:
print(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)
print(f"✅ /process-url via Background task concluído! Record: {record_id}")
except Exception as e:
print(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:
print(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")
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
print(f"📥 Buscando comentários do post {shortcode}...")
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
print(f"✅ Encontrado {len(comments)} comentários para o post.")
except Exception as e:
print(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:
print(f"⚠️ Erro ao enviar notificação de início para o Discord: {_e}")
print(f"📥 Baixando vídeo para processamento: {crop_url if crop_url else video_url}")
response = download_file_with_retry(crop_url if crop_url else video_url, timeout=600)
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: Região útil de texto insuficiente para crop seguro (texto centralizado)."}
})
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 ""
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...")
# Envio do prompt + arquivos (cortado e original) pro Gemini
response_gemini = await client.generate_content(prompt, files=files_to_send, model=model_obj)
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
cropped_video_url = record.get("crop_content_url") # Garantir que pegamos a URL do banco se já existir
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
# 1. Screenshot do vídeo cortado (frame do MEIO)
screenshot_path = tempfile.NamedTemporaryFile(delete=False, suffix='.jpg').name
# Obter duração do vídeo para calcular o meio
duration_probe = subprocess.run(
["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", video_for_export],
capture_output=True, text=True
)
video_duration = float(duration_probe.stdout.strip()) if duration_probe.returncode == 0 and duration_probe.stdout.strip() else 10.0
middle_time = str(video_duration / 2)
ffmpeg_ss = subprocess.run(
["ffmpeg", "-y", "-i", video_for_export, "-ss", middle_time, "-frames:v", "1", screenshot_path],
capture_output=True, text=True
)
if ffmpeg_ss.returncode != 0:
print(f"⚠️ Erro ao tirar screenshot: {ffmpeg_ss.stderr[:200]}")
else:
# 2. Upload do screenshot para recurve-save
print("📸 Enviando screenshot para recurve-save...")
with open(screenshot_path, 'rb') as ss_f:
upload_resp = requests.post(
"https://habulaj-recurve-save.hf.space/upload",
files={'files': ('screenshot.jpg', ss_f, 'image/jpeg')},
timeout=60
)
upload_resp.raise_for_status()
screenshot_url = upload_resp.json().get("url", "")
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...")
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()
cropped_video_url = vid_upload_resp.json().get("url", "")
# 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:
print(f"🎙️ [{time.time()-t_start:.1f}s] Gerando legendas (Groq + Gemini)...")
try:
# Transcricao via Groq usando o arquivo local
import urllib.request as _urlreq
import uuid as _uuid
# Groq precisa de URL — usamos o video já upado (export_video_url)
srt_raw, _, _, _ = await get_groq_srt_base(
export_video_url, language="en", temperature=0.4,
has_bg_music=False
)
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. Se alguém estiver gritando, ESCREVA MAIÚSCULO! etc... 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("Falha ao obter URL do screenshot original.")
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")
url_to_download = record.get("ig_post_url")
context_text = record.get("ig_caption", "")
if not url_to_download:
raise HTTPException(status_code=400, detail=f"Registro ID {record_id} falhou: ig_post_url inválida.")
import urllib.parse
try:
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:
print(f"⚠️ Erro ao enviar mensagem de sistema para o Discord: {e}")
# Buscar duplicados para verificação rigorosa (últimos 50 posts publicados)
print("🔍 Buscando as últimas postagens para evitar duplicação...")
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"
print(f"📥 Baixando mídia para filtro: {url_to_download}")
response = download_file_with_retry(url_to_download, timeout=600)
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)
media_path_to_analyze = temp_file.name
cropped_file_path = None
crop_content_url = None
# 3. Crop (Filtro Inteligente)
if 'image' in content_type:
print(f"✂️ Processando imagem: detectando e cortando...")
try:
cropped_file_path = detect_and_crop_image(media_path_to_analyze)
except Exception as e:
print(f"⚠️ Erro ao cortar imagem: {e}")
else:
print(f"✂️ Processando vídeo: detectando e cortando bordas...")
try:
# Criar arquivo temporário para o vídeo cortado
cropped_video_tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.mp4').name
crop_status = detect_and_crop_video(media_path_to_analyze, cropped_video_tmp)
if crop_status == "aborted_area_too_small":
print(f"🚫 Crop abortado: área de texto no centro. Reprovando filtro.")
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).",
"contains_image": False
})
return # Para o processamento aqui
if crop_status == "success" and os.path.exists(cropped_video_tmp):
cropped_file_path = cropped_video_tmp
else:
if os.path.exists(cropped_video_tmp): os.unlink(cropped_video_tmp)
except Exception as e:
print(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:
print(f"☁️ 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", "")
print(f"✅ Crop content URL: {crop_content_url}")
except Exception as up_e:
print(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")
print(f"🧠 Enviando para Gemini (flash) para filtro de conteúdo...")
response_gemini = await client.generate_content(prompt, files=files_to_send, model=model_obj)
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")
final_content_url = record.get("final_content_url", "")
result_data = record.get("result", [])
filter_message = record.get("filter_message", "")
if not final_content_url:
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):
print(f"🔄 Post #{record_id} já possui publish_message. Tentando publicar direto no Instagram...")
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]
print(f"🚀 Enviando post #{record_id} para API de publicação (RETRY)...")
import json
print(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:
print(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:
print(f"⚠️ Erro ao enviar mensagem de sistema para o Discord: {e}")
# Baixar o conteúdo final para enviar ao Gemini
print(f"📥 Baixando conteúdo final para revisão: {final_content_url}")
response = download_file_with_retry(final_content_url, timeout=600)
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")
print(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)
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
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
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):
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:
try: os.unlink(filepath)
except: pass
srt_base = groq_json_to_srt(result)
word_level = groq_words_to_text(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
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, "en", 0.4, request.has_bg_music, request.time_start, request.time_end
)
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. Se alguém estiver gritando, ESCREVA MAIÚSCULO! etc... 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))