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 get_responsive_font_to_fit_height(text: str, font_path: str, max_width: int, max_height: int, | |
max_font_size: int = 48, min_font_size: int = 20) -> tuple[ImageFont.FreeTypeFont, list[str], int]: | |
temp_img = Image.new("RGB", (1, 1)) | |
draw = ImageDraw.Draw(temp_img) | |
for font_size in range(max_font_size, min_font_size - 1, -1): | |
try: | |
font = ImageFont.truetype(font_path, font_size) | |
except: | |
font = ImageFont.load_default() | |
lines = wrap_text(text, font, max_width, draw) | |
line_height = int(font_size * 1.161) | |
total_height = len(lines) * line_height | |
if total_height <= max_height: | |
return font, lines, font_size | |
# Caso nenhum tamanho sirva, usar o mínimo mesmo assim | |
try: | |
font = ImageFont.truetype(font_path, min_font_size) | |
except: | |
font = ImageFont.load_default() | |
lines = wrap_text(text, font, max_width, draw) | |
return font, lines, min_font_size | |
def download_image_from_url(url: str) -> Image.Image: | |
response = requests.get(url) | |
if response.status_code != 200: | |
raise HTTPException(status_code=400, detail="Imagem não pôde ser baixada.") | |
return Image.open(BytesIO(response.content)).convert("RGBA") | |
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 | |
return img_resized.crop((left, top, left + target_width, top + target_height)) | |
def create_black_gradient_overlay(width: int, height: int) -> Image.Image: | |
gradient = Image.new("RGBA", (width, height)) | |
draw = ImageDraw.Draw(gradient) | |
for y in range(height): | |
opacity = int(255 * (y / height)) | |
draw.line([(0, y), (width, y)], fill=(4, 4, 4, opacity)) | |
return gradient | |
def wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.Draw) -> list[str]: | |
lines = [] | |
for raw_line in text.split("\n"): | |
words = raw_line.split() | |
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) | |
elif not words: | |
lines.append("") # Linha vazia preserva \n\n | |
return lines | |
def get_responsive_font_and_lines(text: str, font_path: str, max_width: int, max_lines: int = 3, | |
max_font_size: int = 50, min_font_size: int = 20) -> tuple[ImageFont.FreeTypeFont, list[str], int]: | |
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: | |
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: | |
font = ImageFont.load_default() | |
lines = wrap_text(text, font, max_width, temp_draw) | |
return font, lines, min_font_size | |
def generate_slide_1(image_url: Optional[str], headline: Optional[str]) -> Image.Image: | |
width, height = 1080, 1350 | |
canvas = Image.new("RGBA", (width, height), color=(255, 255, 255, 255)) | |
if image_url: | |
try: | |
img = download_image_from_url(image_url) | |
filled_img = resize_and_crop_to_fill(img, width, height) | |
canvas.paste(filled_img, (0, 0)) | |
except Exception as e: | |
raise HTTPException(status_code=400, detail=f"Erro ao processar imagem de fundo: {e}") | |
# Gradiente | |
gradient_overlay = create_black_gradient_overlay(width, height) | |
canvas = Image.alpha_composite(canvas, gradient_overlay) | |
draw = ImageDraw.Draw(canvas) | |
# Logo no topo | |
try: | |
logo = Image.open("recurvecuriosity.png").convert("RGBA").resize((368, 29)) | |
canvas.paste(logo, (66, 74), logo) | |
except Exception as e: | |
raise HTTPException(status_code=500, detail=f"Erro ao carregar recurvecuriosity.png: {e}") | |
# Imagem arrastar no rodapé | |
try: | |
arrow = Image.open("arrastar.png").convert("RGBA").resize((355, 37)) | |
canvas.paste(arrow, (66, 1240), arrow) | |
except Exception as e: | |
raise HTTPException(status_code=500, detail=f"Erro ao carregar arrastar.png: {e}") | |
# Texto headline acima da imagem arrastar | |
if headline: | |
font_path = "fonts/Montserrat-Bold.ttf" | |
max_width = 945 | |
max_lines = 3 | |
try: | |
font, lines, font_size = get_responsive_font_and_lines( | |
headline, font_path, max_width, max_lines=max_lines, | |
max_font_size=50, min_font_size=20 | |
) | |
line_height = int(font_size * 1.161) | |
except Exception as e: | |
raise HTTPException(status_code=500, detail=f"Erro ao processar fonte/headline: {e}") | |
total_text_height = len(lines) * line_height | |
start_y = 1240 - 16 - total_text_height | |
x = (width - max_width) // 2 | |
for i, line in enumerate(lines): | |
y = start_y + i * line_height | |
draw.text((x, y), line, font=font, fill=(255, 255, 255)) | |
return canvas | |
def generate_slide_2(image_url: Optional[str], headline: Optional[str]) -> Image.Image: | |
width, height = 1080, 1350 | |
canvas = Image.new("RGBA", (width, height), color=(4, 4, 4, 255)) | |
draw = ImageDraw.Draw(canvas) | |
# === Imagem principal === | |
if image_url: | |
try: | |
img = download_image_from_url(image_url) | |
resized = resize_and_crop_to_fill(img, 1080, 830) | |
canvas.paste(resized, (0, 0)) | |
except Exception as e: | |
raise HTTPException(status_code=400, detail=f"Erro ao processar imagem do slide 2: {e}") | |
# === Headline === | |
if headline: | |
font_path = "fonts/Montserrat-SemiBold.ttf" | |
max_width = 945 | |
top_y = 830 + 70 | |
bottom_padding = 70 # Alterado de 70 para 70 (já estava correto) | |
available_height = height - top_y - bottom_padding | |
try: | |
font, lines, font_size = get_responsive_font_to_fit_height( | |
headline, | |
font_path=font_path, | |
max_width=max_width, | |
max_height=available_height, | |
max_font_size=48, | |
min_font_size=20 | |
) | |
line_height = int(font_size * 1.161) | |
except Exception as e: | |
raise HTTPException(status_code=500, detail=f"Erro ao processar texto do slide 2: {e}") | |
x = (width - max_width) // 2 | |
for i, line in enumerate(lines): | |
y = top_y + i * line_height | |
draw.text((x, y), line, font=font, fill=(255, 255, 255)) | |
return canvas | |
def generate_slide_3(image_url: Optional[str], headline: Optional[str]) -> Image.Image: | |
width, height = 1080, 1350 | |
canvas = Image.new("RGBA", (width, height), color=(4, 4, 4, 255)) | |
draw = ImageDraw.Draw(canvas) | |
# === Imagem com cantos arredondados à esquerda === | |
if image_url: | |
try: | |
img = download_image_from_url(image_url) | |
resized = resize_and_crop_to_fill(img, 990, 750) | |
# Máscara arredondando cantos esquerdos | |
mask = Image.new("L", (990, 750), 0) | |
mask_draw = ImageDraw.Draw(mask) | |
mask_draw.rectangle((25, 0, 990, 750), fill=255) | |
mask_draw.pieslice([0, 0, 50, 50], 180, 270, fill=255) | |
mask_draw.pieslice([0, 700, 50, 750], 90, 180, fill=255) | |
mask_draw.rectangle((0, 25, 25, 725), fill=255) | |
canvas.paste(resized, (90, 422), mask) | |
except Exception as e: | |
raise HTTPException(status_code=400, detail=f"Erro ao processar imagem do slide 3: {e}") | |
# === Headline acima da imagem === | |
if headline: | |
font_path = "fonts/Montserrat-SemiBold.ttf" | |
max_width = 945 | |
image_top_y = 422 | |
spacing = 50 | |
bottom_of_text = image_top_y - spacing | |
safe_top = 70 # Alterado de 70 para 70 (já estava correto) | |
available_height = bottom_of_text - safe_top | |
font_size = 48 | |
while font_size >= 20: | |
try: | |
font = ImageFont.truetype(font_path, font_size) | |
except: | |
font = ImageFont.load_default() | |
lines = wrap_text(headline, font, max_width, draw) | |
line_height = int(font_size * 1.161) | |
total_text_height = len(lines) * line_height | |
start_y = bottom_of_text - total_text_height | |
if start_y >= safe_top: | |
break | |
font_size -= 1 | |
try: | |
font = ImageFont.truetype(font_path, font_size) | |
except: | |
font = ImageFont.load_default() | |
x = 90 | |
for i, line in enumerate(lines): | |
y = start_y + i * line_height | |
draw.text((x, y), line, font=font, fill=(255, 255, 255)) | |
return canvas | |
def generate_slide_4(image_url: Optional[str], headline: Optional[str]) -> Image.Image: | |
width, height = 1080, 1350 | |
canvas = Image.new("RGBA", (width, height), color=(4, 4, 4, 255)) | |
draw = ImageDraw.Draw(canvas) | |
# === Imagem com cantos arredondados à esquerda === | |
if image_url: | |
try: | |
img = download_image_from_url(image_url) | |
resized = resize_and_crop_to_fill(img, 990, 750) | |
# Máscara com cantos arredondados à esquerda | |
mask = Image.new("L", (990, 750), 0) | |
mask_draw = ImageDraw.Draw(mask) | |
mask_draw.rectangle((25, 0, 990, 750), fill=255) | |
mask_draw.pieslice([0, 0, 50, 50], 180, 270, fill=255) | |
mask_draw.pieslice([0, 700, 50, 750], 90, 180, fill=255) | |
mask_draw.rectangle((0, 25, 25, 725), fill=255) | |
canvas.paste(resized, (90, 178), mask) | |
except Exception as e: | |
raise HTTPException(status_code=400, detail=f"Erro ao processar imagem do slide 4: {e}") | |
# === Headline abaixo da imagem === | |
if headline: | |
font_path = "fonts/Montserrat-SemiBold.ttf" | |
max_width = 945 | |
top_of_text = 178 + 750 + 50 # Y da imagem + altura + espaçamento | |
safe_bottom = 70 # Alterado de 50 para 70 | |
available_height = height - top_of_text - safe_bottom | |
try: | |
font, lines, font_size = get_responsive_font_to_fit_height( | |
headline, | |
font_path=font_path, | |
max_width=max_width, | |
max_height=available_height, | |
max_font_size=48, | |
min_font_size=20 | |
) | |
line_height = int(font_size * 1.161) | |
except Exception as e: | |
raise HTTPException(status_code=500, detail=f"Erro ao processar texto do slide 4: {e}") | |
x = 90 | |
for i, line in enumerate(lines): | |
y = top_of_text + i * line_height | |
draw.text((x, y), line, font=font, fill=(255, 255, 255)) | |
return canvas | |
def generate_slide_5(image_url: Optional[str], headline: Optional[str]) -> Image.Image: | |
width, height = 1080, 1350 | |
canvas = Image.new("RGBA", (width, height), color=(4, 4, 4, 255)) | |
draw = ImageDraw.Draw(canvas) | |
image_w, image_h = 900, 748 | |
image_x = 90 | |
image_y = 100 | |
# === Imagem com cantos totalmente arredondados === | |
if image_url: | |
try: | |
img = download_image_from_url(image_url) | |
resized = resize_and_crop_to_fill(img, image_w, image_h) | |
# Máscara com cantos 25px arredondados (todos os cantos) | |
radius = 25 | |
mask = Image.new("L", (image_w, image_h), 0) | |
mask_draw = ImageDraw.Draw(mask) | |
mask_draw.rounded_rectangle((0, 0, image_w, image_h), radius=radius, fill=255) | |
canvas.paste(resized, (image_x, image_y), mask) | |
except Exception as e: | |
raise HTTPException(status_code=400, detail=f"Erro ao processar imagem do slide 5: {e}") | |
# === Texto abaixo da imagem === | |
if headline: | |
font_path = "fonts/Montserrat-SemiBold.ttf" | |
max_width = 945 | |
top_of_text = image_y + image_h + 50 | |
safe_bottom = 70 # Alterado de 50 para 70 | |
available_height = height - top_of_text - safe_bottom | |
try: | |
font, lines, font_size = get_responsive_font_to_fit_height( | |
headline, | |
font_path=font_path, | |
max_width=max_width, | |
max_height=available_height, | |
max_font_size=48, | |
min_font_size=20 | |
) | |
line_height = int(font_size * 1.161) | |
except Exception as e: | |
raise HTTPException(status_code=500, detail=f"Erro ao processar texto do slide 5: {e}") | |
x = (width - max_width) // 2 # Centralizado horizontalmente | |
for i, line in enumerate(lines): | |
y = top_of_text + i * line_height | |
draw.text((x, y), line, font=font, fill=(255, 255, 255)) | |
return canvas | |
def generate_black_canvas() -> Image.Image: | |
return Image.new("RGB", (1080, 1350), color=(4, 4, 4)) | |
def get_curiosity_image( | |
image_url: Optional[str] = Query(None, description="URL da imagem de fundo"), | |
headline: Optional[str] = Query(None, description="Texto da curiosidade"), | |
slide: int = Query(1, ge=1, le=5, description="Número do slide (1 a 5)") | |
): | |
try: | |
if slide == 1: | |
final_image = generate_slide_1(image_url, headline) | |
elif slide == 2: | |
final_image = generate_slide_2(image_url, headline) | |
elif slide == 3: | |
final_image = generate_slide_3(image_url, headline) | |
elif slide == 4: | |
final_image = generate_slide_4(image_url, headline) | |
elif slide == 5: | |
final_image = generate_slide_5(image_url, headline) | |
else: | |
final_image = generate_black_canvas() | |
buffer = BytesIO() | |
final_image.convert("RGB").save(buffer, format="PNG") | |
buffer.seek(0) | |
return StreamingResponse(buffer, media_type="image/png") | |
except Exception as e: | |
raise HTTPException(status_code=500, detail=f"Erro ao gerar imagem: {str(e)}") |