Spaces:
Runtime error
Runtime error
| 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, List, Union | |
| 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_collage_background(image_urls: List[str], canvas_width: int, canvas_height: int, device: str) -> Image.Image: | |
| """Cria uma colagem como fundo baseada na lista de URLs""" | |
| num_images = len(image_urls) | |
| border_size = 4 if num_images > 1 else 0 # Linha mais fina e elegante | |
| is_web = device.lower() == "web" | |
| images = [download_image_from_url(url) for url in image_urls] | |
| canvas = Image.new("RGBA", (canvas_width, canvas_height), (255, 255, 255, 255)) | |
| if num_images == 1: | |
| img = resize_and_crop_to_fill(images[0], canvas_width, canvas_height) | |
| canvas.paste(img, (0, 0)) | |
| elif num_images == 2: | |
| # Ambos dispositivos: lado a lado | |
| slot_width = (canvas_width - border_size) // 2 | |
| img1 = resize_and_crop_to_fill(images[0], slot_width, canvas_height) | |
| img2 = resize_and_crop_to_fill(images[1], slot_width, canvas_height) | |
| canvas.paste(img1, (0, 0)) | |
| canvas.paste(img2, (slot_width + border_size, 0)) | |
| elif num_images == 3: | |
| if is_web: | |
| # Web: 1 grande à esquerda, 2 pequenas empilhadas à direita | |
| left_width = (canvas_width - border_size) * 2 // 3 | |
| right_width = canvas_width - left_width - border_size | |
| half_height = (canvas_height - border_size) // 2 | |
| img1 = resize_and_crop_to_fill(images[0], left_width, canvas_height) | |
| img2 = resize_and_crop_to_fill(images[1], right_width, half_height) | |
| img3 = resize_and_crop_to_fill(images[2], right_width, half_height) | |
| canvas.paste(img1, (0, 0)) | |
| canvas.paste(img2, (left_width + border_size, 0)) | |
| canvas.paste(img3, (left_width + border_size, half_height + border_size)) | |
| else: | |
| # IG: layout original | |
| half_height = (canvas_height - border_size) // 2 | |
| half_width = (canvas_width - border_size) // 2 | |
| img1 = resize_and_crop_to_fill(images[0], half_width, half_height) | |
| img2 = resize_and_crop_to_fill(images[1], half_width, half_height) | |
| img3 = resize_and_crop_to_fill(images[2], canvas_width, half_height) | |
| canvas.paste(img1, (0, 0)) | |
| canvas.paste(img2, (half_width + border_size, 0)) | |
| canvas.paste(img3, (0, half_height + border_size)) | |
| elif num_images == 4: | |
| if is_web: | |
| # Web: 4 imagens lado a lado horizontalmente | |
| slot_width = (canvas_width - 3 * border_size) // 4 | |
| for i in range(4): | |
| img = resize_and_crop_to_fill(images[i], slot_width, canvas_height) | |
| x_pos = i * (slot_width + border_size) | |
| canvas.paste(img, (x_pos, 0)) | |
| else: | |
| # IG: Layout 2x2 | |
| half_height = (canvas_height - border_size) // 2 | |
| half_width = (canvas_width - border_size) // 2 | |
| img1 = resize_and_crop_to_fill(images[0], half_width, half_height) | |
| img2 = resize_and_crop_to_fill(images[1], half_width, half_height) | |
| img3 = resize_and_crop_to_fill(images[2], half_width, half_height) | |
| img4 = resize_and_crop_to_fill(images[3], half_width, half_height) | |
| canvas.paste(img1, (0, 0)) | |
| canvas.paste(img2, (half_width + border_size, 0)) | |
| canvas.paste(img3, (0, half_height + border_size)) | |
| canvas.paste(img4, (half_width + border_size, half_height + border_size)) | |
| elif num_images == 5: | |
| if is_web: | |
| # Web: 3 em cima, 2 embaixo | |
| top_height = (canvas_height - border_size) * 3 // 5 | |
| bottom_height = canvas_height - top_height - border_size | |
| available_width = canvas_width - 2 * border_size | |
| third_width = available_width // 3 | |
| third_width_last = canvas_width - (third_width * 2 + border_size * 2) | |
| half_width = (canvas_width - border_size) // 2 | |
| # 3 imagens em cima | |
| img1 = resize_and_crop_to_fill(images[0], third_width, top_height) | |
| img2 = resize_and_crop_to_fill(images[1], third_width, top_height) | |
| img3 = resize_and_crop_to_fill(images[2], third_width_last, top_height) | |
| canvas.paste(img1, (0, 0)) | |
| canvas.paste(img2, (third_width + border_size, 0)) | |
| canvas.paste(img3, (third_width * 2 + border_size * 2, 0)) | |
| # 2 imagens embaixo | |
| y_offset = top_height + border_size | |
| img4 = resize_and_crop_to_fill(images[3], half_width, bottom_height) | |
| img5 = resize_and_crop_to_fill(images[4], half_width, bottom_height) | |
| canvas.paste(img4, (0, y_offset)) | |
| canvas.paste(img5, (half_width + border_size, y_offset)) | |
| else: | |
| # IG: layout original | |
| top_height = (canvas_height - border_size) * 2 // 5 | |
| bottom_height = canvas_height - top_height - border_size | |
| half_width = (canvas_width - border_size) // 2 | |
| img1 = resize_and_crop_to_fill(images[0], half_width, top_height) | |
| img2 = resize_and_crop_to_fill(images[1], half_width, top_height) | |
| canvas.paste(img1, (0, 0)) | |
| canvas.paste(img2, (half_width + border_size, 0)) | |
| y_offset = top_height + border_size | |
| third_width = (canvas_width - 2 * border_size) // 3 | |
| third_width_last = canvas_width - (third_width * 2 + border_size * 2) | |
| img3 = resize_and_crop_to_fill(images[2], third_width, bottom_height) | |
| img4 = resize_and_crop_to_fill(images[3], third_width, bottom_height) | |
| img5 = resize_and_crop_to_fill(images[4], third_width_last, bottom_height) | |
| canvas.paste(img3, (0, y_offset)) | |
| canvas.paste(img4, (third_width + border_size, y_offset)) | |
| canvas.paste(img5, (third_width * 2 + border_size * 2, y_offset)) | |
| elif num_images == 6: | |
| if is_web: | |
| # Web: 3x2 (3 colunas, 2 linhas) | |
| half_height = (canvas_height - border_size) // 2 | |
| available_width = canvas_width - 2 * border_size | |
| third_width = available_width // 3 | |
| third_width_last = canvas_width - (third_width * 2 + border_size * 2) | |
| # Primeira linha | |
| img1 = resize_and_crop_to_fill(images[0], third_width, half_height) | |
| img2 = resize_and_crop_to_fill(images[1], third_width, half_height) | |
| img3 = resize_and_crop_to_fill(images[2], third_width_last, half_height) | |
| canvas.paste(img1, (0, 0)) | |
| canvas.paste(img2, (third_width + border_size, 0)) | |
| canvas.paste(img3, (third_width * 2 + border_size * 2, 0)) | |
| # Segunda linha | |
| y_offset = half_height + border_size | |
| img4 = resize_and_crop_to_fill(images[3], third_width, half_height) | |
| img5 = resize_and_crop_to_fill(images[4], third_width, half_height) | |
| img6 = resize_and_crop_to_fill(images[5], third_width_last, half_height) | |
| canvas.paste(img4, (0, y_offset)) | |
| canvas.paste(img5, (third_width + border_size, y_offset)) | |
| canvas.paste(img6, (third_width * 2 + border_size * 2, y_offset)) | |
| else: | |
| # IG: layout original (3x2) | |
| half_height = (canvas_height - border_size) // 2 | |
| third_width = (canvas_width - 2 * border_size) // 3 | |
| third_width_last = canvas_width - (third_width * 2 + border_size * 2) | |
| # Primeira linha | |
| img1 = resize_and_crop_to_fill(images[0], third_width, half_height) | |
| img2 = resize_and_crop_to_fill(images[1], third_width, half_height) | |
| img3 = resize_and_crop_to_fill(images[2], third_width, half_height) | |
| canvas.paste(img1, (0, 0)) | |
| canvas.paste(img2, (third_width + border_size, 0)) | |
| canvas.paste(img3, (third_width * 2 + border_size * 2, 0)) | |
| # Segunda linha | |
| y_offset = half_height + border_size | |
| img4 = resize_and_crop_to_fill(images[3], third_width, half_height) | |
| img5 = resize_and_crop_to_fill(images[4], third_width, half_height) | |
| img6 = resize_and_crop_to_fill(images[5], third_width_last, half_height) | |
| canvas.paste(img4, (0, y_offset)) | |
| canvas.paste(img5, (third_width + border_size, y_offset)) | |
| canvas.paste(img6, (third_width * 2 + border_size * 2, y_offset)) | |
| else: | |
| raise HTTPException(status_code=400, detail="Apenas até 6 imagens são suportadas.") | |
| return canvas | |
| def parse_image_urls(image_url_param: Union[str, List[str]]) -> List[str]: | |
| """Converte o parâmetro de URL(s) em lista de URLs""" | |
| if isinstance(image_url_param, list): | |
| return image_url_param | |
| elif isinstance(image_url_param, str): | |
| # Se contém vírgulas, divide em múltiplas URLs | |
| if ',' in image_url_param: | |
| return [url.strip() for url in image_url_param.split(',') if url.strip()] | |
| else: | |
| return [image_url_param] | |
| return [] | |
| 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[Union[str, List[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(s) de fundo se fornecida(s) | |
| if image_url: | |
| parsed_urls = parse_image_urls(image_url) | |
| if parsed_urls: | |
| if len(parsed_urls) > 6: | |
| raise HTTPException(status_code=400, detail="Máximo de 6 imagens permitidas") | |
| if len(parsed_urls) == 1: | |
| # Uma única imagem - comportamento original | |
| img = download_image_from_url(parsed_urls[0]) | |
| filled_img = resize_and_crop_to_fill(img, width, height) | |
| canvas.paste(filled_img, (0, 0)) | |
| else: | |
| # Múltiplas imagens - criar colagem | |
| canvas = create_collage_background(parsed_urls, width, height, device) | |
| # 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: | |
| pass | |
| buffer = BytesIO() | |
| canvas.convert("RGB").save(buffer, format="PNG") | |
| buffer.seek(0) | |
| return buffer | |
| def get_news_image( | |
| image_url: Optional[Union[str, List[str]]] = Query(None, description="URL da imagem ou lista de URLs separadas por vírgula para colagem (máximo 6)"), | |
| 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)}") |