Spaces:
Running
Running
from fastapi import APIRouter, Query, HTTPException | |
from fastapi.responses import StreamingResponse | |
from PIL import Image, ImageDraw, ImageFont | |
from io import BytesIO | |
import requests | |
from typing import Optional | |
router = APIRouter() | |
def download_image_from_url(url: str) -> Image.Image: | |
headers = { | |
"User-Agent": ( | |
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) " | |
"AppleWebKit/537.36 (KHTML, like Gecko) " | |
"Chrome/115.0.0.0 Safari/537.36" | |
) | |
} | |
try: | |
response = requests.get(url, headers=headers, timeout=10) | |
response.raise_for_status() | |
return Image.open(BytesIO(response.content)).convert("RGBA") | |
except Exception as e: | |
raise HTTPException(status_code=400, detail=f"Erro ao baixar imagem: {url} ({str(e)})") | |
def resize_and_crop_to_fill(img: Image.Image, target_width: int, target_height: int) -> Image.Image: | |
img_ratio = img.width / img.height | |
target_ratio = target_width / target_height | |
if img_ratio > target_ratio: | |
scale_height = target_height | |
scale_width = int(scale_height * img_ratio) | |
else: | |
scale_width = target_width | |
scale_height = int(scale_width / img_ratio) | |
img_resized = img.resize((scale_width, scale_height), Image.LANCZOS) | |
left = (scale_width - target_width) // 2 | |
top = (scale_height - target_height) // 2 | |
right = left + target_width | |
bottom = top + target_height | |
return img_resized.crop((left, top, right, bottom)) | |
def create_gradient_overlay(width: int, height: int, text_position: str = "bottom") -> Image.Image: | |
""" | |
Cria gradiente overlay baseado na posição do texto | |
""" | |
gradient = Image.new("RGBA", (width, height)) | |
draw = ImageDraw.Draw(gradient) | |
if text_position.lower() == "bottom": | |
# Gradiente para texto embaixo: posição Y:531, altura 835px | |
gradient_start = 531 | |
gradient_height = 835 | |
for y in range(gradient_height): | |
if y + gradient_start < height: | |
# Gradient: 0% transparent -> 46.63% rgba(0,0,0,0.55) -> 100% rgba(0,0,0,0.7) | |
ratio = y / gradient_height | |
if ratio <= 0.4663: | |
# 0% a 46.63%: de transparente para 0.55 | |
opacity_ratio = ratio / 0.4663 | |
opacity = int(255 * 0.55 * opacity_ratio) | |
else: | |
# 46.63% a 100%: de 0.55 para 0.7 | |
opacity_ratio = (ratio - 0.4663) / (1 - 0.4663) | |
opacity = int(255 * (0.55 + (0.7 - 0.55) * opacity_ratio)) | |
draw.line([(0, y + gradient_start), (width, y + gradient_start)], fill=(0, 0, 0, opacity)) | |
else: # text_position == "top" | |
# Gradiente para texto no topo: posição Y:0, altura 835px | |
# linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.307045) 16.93%, rgba(0,0,0,0.55) 45.57%, rgba(0,0,0,0.7) 100%) | |
# 0deg significa: 0% = bottom, 100% = top | |
gradient_height = 835 | |
for y in range(gradient_height): | |
if y < height: | |
# Inverter a ratio: y=0 (topo) deve ser 100% do gradient, y=835 (bottom) deve ser 0% | |
ratio = (gradient_height - y) / gradient_height | |
if ratio <= 0.1693: | |
# 0% a 16.93%: de 0 (transparente) para 0.307 | |
opacity_ratio = ratio / 0.1693 | |
opacity = int(255 * (0.307 * opacity_ratio)) | |
elif ratio <= 0.4557: | |
# 16.93% a 45.57%: de 0.307 para 0.55 | |
opacity_ratio = (ratio - 0.1693) / (0.4557 - 0.1693) | |
opacity = int(255 * (0.307 + (0.55 - 0.307) * opacity_ratio)) | |
else: | |
# 45.57% a 100%: de 0.55 para 0.7 | |
opacity_ratio = (ratio - 0.4557) / (1 - 0.4557) | |
opacity = int(255 * (0.55 + (0.7 - 0.55) * opacity_ratio)) | |
draw.line([(0, y), (width, y)], fill=(0, 0, 0, opacity)) | |
return gradient | |
def wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.Draw) -> list[str]: | |
words = text.split() | |
lines = [] | |
current_line = "" | |
for word in words: | |
test_line = f"{current_line} {word}".strip() | |
if draw.textlength(test_line, font=font) <= max_width: | |
current_line = test_line | |
else: | |
if current_line: | |
lines.append(current_line) | |
current_line = word | |
if current_line: | |
lines.append(current_line) | |
return lines | |
def get_responsive_font_and_lines(text: str, font_path: str, max_width: int, max_lines: int = 3, | |
max_font_size: int = 80, min_font_size: int = 20) -> tuple[ImageFont.FreeTypeFont, list[str], int]: | |
""" | |
Retorna a fonte e linhas ajustadas para caber no número máximo de linhas. | |
""" | |
temp_img = Image.new("RGB", (1, 1)) | |
temp_draw = ImageDraw.Draw(temp_img) | |
current_font_size = max_font_size | |
while current_font_size >= min_font_size: | |
try: | |
font = ImageFont.truetype(font_path, current_font_size) | |
except Exception: | |
font = ImageFont.load_default() | |
lines = wrap_text(text, font, max_width, temp_draw) | |
if len(lines) <= max_lines: | |
return font, lines, current_font_size | |
current_font_size -= 1 | |
try: | |
font = ImageFont.truetype(font_path, min_font_size) | |
except Exception: | |
font = ImageFont.load_default() | |
lines = wrap_text(text, font, max_width, temp_draw) | |
return font, lines, min_font_size | |
def get_font_and_lines(text: str, font_path: str, font_size: int, max_width: int) -> tuple[ImageFont.FreeTypeFont, list[str]]: | |
""" | |
Retorna a fonte e linhas com tamanho fixo de fonte. | |
""" | |
try: | |
font = ImageFont.truetype(font_path, font_size) | |
except Exception: | |
font = ImageFont.load_default() | |
temp_img = Image.new("RGB", (1, 1)) | |
temp_draw = ImageDraw.Draw(temp_img) | |
lines = wrap_text(text, font, max_width, temp_draw) | |
return font, lines | |
def get_text_color_rgb(text_color: str) -> tuple[int, int, int]: | |
""" | |
Converte o parâmetro text_color para RGB. | |
""" | |
if text_color.lower() == "black": | |
return (0, 0, 0) | |
else: # white por padrão | |
return (255, 255, 255) | |
def get_device_dimensions(device: str) -> tuple[int, int]: | |
"""Retorna as dimensões baseadas no dispositivo""" | |
if device.lower() == "web": | |
return (1280, 720) | |
else: # Instagram por padrão | |
return (1080, 1350) | |
def create_canvas(image_url: Optional[str], headline: Optional[str], device: str = "ig", | |
text_position: str = "bottom", text_color: str = "white") -> BytesIO: | |
width, height = get_device_dimensions(device) | |
is_web = device.lower() == "web" | |
text_rgb = get_text_color_rgb(text_color) | |
# Configurações específicas por dispositivo | |
if is_web: | |
padding_x = 40 | |
logo_width, logo_height = 120, 22 | |
logo_padding = 40 | |
else: | |
padding_x = 60 | |
bottom_padding = 80 | |
top_padding = 60 | |
logo_width, logo_height = 121, 23 # Novas dimensões: L:121, A:22.75 (arredondado para 23) | |
max_width = width - 2 * padding_x | |
canvas = Image.new("RGBA", (width, height), color=(255, 255, 255, 255)) | |
# Adicionar imagem de fundo se fornecida | |
if image_url: | |
img = download_image_from_url(image_url) | |
filled_img = resize_and_crop_to_fill(img, width, height) | |
canvas.paste(filled_img, (0, 0)) | |
# Para Instagram: adicionar gradiente e texto | |
if not is_web: | |
# Só aplicar gradiente se o texto for branco | |
if text_color.lower() != "black": | |
gradient_overlay = create_gradient_overlay(width, height, text_position) | |
canvas = Image.alpha_composite(canvas, gradient_overlay) | |
if headline: | |
draw = ImageDraw.Draw(canvas) | |
font_path = "fonts/AGaramondPro-Semibold.ttf" | |
line_height_factor = 1.05 # 105% da altura da linha | |
try: | |
font, lines, font_size = get_responsive_font_and_lines( | |
headline, font_path, max_width, max_lines=3, | |
max_font_size=80, min_font_size=20 | |
) | |
line_height = int(font_size * line_height_factor) | |
except Exception as e: | |
raise HTTPException(status_code=500, detail=f"Erro ao processar a fonte: {e}") | |
total_text_height = len(lines) * line_height | |
# Posicionar texto baseado no parâmetro text_position | |
if text_position.lower() == "bottom": | |
# Posicionar texto 50px acima da logo (que está em Y:1274) | |
text_end_y = 1274 - 50 | |
start_y = text_end_y - total_text_height | |
else: # text_position == "top" | |
# Posicionar texto no topo com padding | |
start_y = top_padding | |
# Adicionar logo no canto inferior direito (posição fixa) | |
try: | |
logo_path = "recurve.png" | |
logo = Image.open(logo_path).convert("RGBA") | |
logo_resized = logo.resize((logo_width, logo_height)) | |
# Aplicar opacidade de 42% | |
logo_with_opacity = Image.new("RGBA", logo_resized.size) | |
for x in range(logo_resized.width): | |
for y in range(logo_resized.height): | |
r, g, b, a = logo_resized.getpixel((x, y)) | |
new_alpha = int(a * 0.42) # 42% de opacidade | |
logo_with_opacity.putpixel((x, y), (r, g, b, new_alpha)) | |
# Posição fixa: X:891, Y:1274 | |
canvas.paste(logo_with_opacity, (891, 1274), logo_with_opacity) | |
except Exception as e: | |
raise HTTPException(status_code=500, detail=f"Erro ao carregar a logo: {e}") | |
# Adiciona texto com a cor especificada | |
for i, line in enumerate(lines): | |
y = start_y + i * line_height | |
draw.text((padding_x, y), line, font=font, fill=text_rgb) | |
# Para web: apenas logo no canto inferior direito | |
else: | |
try: | |
logo_path = "recurve.png" | |
logo = Image.open(logo_path).convert("RGBA") | |
logo_resized = logo.resize((logo_width, logo_height)) | |
logo_x = width - logo_width - logo_padding | |
logo_y = height - logo_height - logo_padding | |
canvas.paste(logo_resized, (logo_x, logo_y), logo_resized) | |
except Exception as e: | |
raise HTTPException(status_code=500, detail=f"Erro ao carregar a logo: {e}") | |
buffer = BytesIO() | |
canvas.convert("RGB").save(buffer, format="PNG") | |
buffer.seek(0) | |
return buffer | |
def get_news_image( | |
image_url: Optional[str] = Query(None, description="URL da imagem de fundo"), | |
headline: Optional[str] = Query(None, description="Texto do título (opcional para IG, ignorado para web)"), | |
device: str = Query("ig", description="Dispositivo: 'ig' para Instagram (1080x1350) ou 'web' para Web (1280x720)"), | |
text_position: str = Query("bottom", description="Posição do texto: 'top' para topo ou 'bottom' para parte inferior"), | |
text_color: str = Query("white", description="Cor do texto: 'white' (padrão) ou 'black'. Se 'black', remove o gradiente de fundo") | |
): | |
try: | |
buffer = create_canvas(image_url, headline, device, text_position, text_color) | |
return StreamingResponse(buffer, media_type="image/png") | |
except Exception as e: | |
raise HTTPException(status_code=500, detail=f"Erro ao gerar imagem: {str(e)}") |