import gradio as gr from PIL import Image, ImageDraw, ImageFont, ImageFilter import numpy as np import os import tempfile from pathlib import Path from typing import Tuple, List import requests from io import BytesIO # Configurações da thumbnail THUMB_SIZE = (1280, 720) # Tamanho padrão do YouTube MARGIN = 40 MAX_FILE_SIZE = 1.5 * 1024 * 1024 # 1.5 MB QUALITY = 85 # URLs das fontes TrueType do Google Fonts FONT_URLS = { "title": "https://github.com/google/fonts/raw/main/ofl/montserrat/Montserrat-Black.ttf", "channel": "https://github.com/google/fonts/raw/main/ofl/poppins/Poppins-Bold.ttf", "title_fallback": "https://github.com/google/fonts/raw/main/apache/roboto/Roboto-Bold.ttf" } # Cache para fontes já baixadas FONT_CACHE = {} FONT_FILES = {} def download_font(font_type: str) -> str: """Baixa a fonte e salva em arquivo temporário""" if font_type in FONT_FILES: return FONT_FILES[font_type] try: font_url = FONT_URLS.get(font_type, FONT_URLS["title_fallback"]) # Baixa a fonte response = requests.get(font_url, timeout=10) response.raise_for_status() # Salva em arquivo temporário temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.ttf') temp_file.write(response.content) temp_file.close() FONT_FILES[font_type] = temp_file.name return temp_file.name except Exception as e: print(f"Erro ao baixar fonte {font_type}: {e}") return None def load_font(font_type: str, size: int) -> ImageFont.FreeTypeFont: """Carrega uma fonte do arquivo ou cache""" cache_key = f"{font_type}_{size}" if cache_key in FONT_CACHE: return FONT_CACHE[cache_key] try: # Tenta baixar e carregar a fonte específica font_path = download_font(font_type) if font_path and os.path.exists(font_path): font = ImageFont.truetype(font_path, size) FONT_CACHE[cache_key] = font return font except Exception as e: print(f"Erro ao carregar fonte {font_type}: {e}") # Fallback: tenta fontes do sistema system_fonts = [ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Linux "C:\\Windows\\Fonts\\arialbd.ttf", # Windows "/System/Library/Fonts/Helvetica.ttc", # macOS "arial.ttf", "Arial.ttf", "DejaVuSans-Bold.ttf" ] for font_path in system_fonts: try: font = ImageFont.truetype(font_path, size) FONT_CACHE[cache_key] = font return font except: continue # Último recurso: fonte padrão try: font = ImageFont.load_default() FONT_CACHE[cache_key] = font return font except: # Se tudo falhar, cria uma fonte básica print("AVISO: Usando fonte padrão do PIL") return ImageFont.load_default() # Paletas de cores modernas e chamativas COLOR_PALETTES = { "Neon Cyberpunk": { "title_bg": ["#FF00FF", "#00FFFF", "#FFFF00"], "channel_bg": ["#0000FF", "#FF0000"], "text": "#FFFFFF", "shadow": "#000000" }, "Dark Modern": { "title_bg": ["#1A1A2E", "#16213E", "#0F3460"], "channel_bg": ["#E94560"], "text": "#FFFFFF", "shadow": "#000000" }, "Gradient Sunset": { "title_bg": ["#FF512F", "#DD2476", "#F09819"], "channel_bg": ["#8A2387", "#F27121"], "text": "#FFFFFF", "shadow": "#000000" }, "Electric Blue": { "title_bg": ["#00DBDE", "#FC00FF"], "channel_bg": ["#FFE53B", "#FF2525"], "text": "#000000", "shadow": "#FFFFFF" }, "Lime Punch": { "title_bg": ["#A8FF78", "#78FFD6"], "channel_bg": ["#FF5F6D", "#FFC371"], "text": "#000000", "shadow": "#FFFFFF" } } def get_text_dimensions(text: str, font: ImageFont.FreeTypeFont) -> Tuple[int, int]: """Calcula as dimensões do texto""" try: bbox = font.getbbox(text) return bbox[2] - bbox[0], bbox[3] - bbox[1] except: # Fallback para fonte padrão return len(text) * 10, 15 def create_gradient_bg(size: Tuple[int, int], colors: List[str]) -> Image.Image: """Cria um gradiente de fundo""" width, height = size # Cria uma imagem em branco gradient = Image.new('RGB', size) draw = ImageDraw.Draw(gradient) # Se houver apenas uma cor, preenche uniformemente if len(colors) == 1: draw.rectangle([0, 0, width, height], fill=colors[0]) return gradient # Divide a altura pelo número de cores step = max(1, height // (len(colors) - 1)) for i in range(len(colors) - 1): color1 = colors[i] color2 = colors[i + 1] # Converte cores hex para RGB r1, g1, b1 = int(color1[1:3], 16), int(color1[3:5], 16), int(color1[5:7], 16) r2, g2, b2 = int(color2[1:3], 16), int(color2[3:5], 16), int(color2[5:7], 16) for y in range(step): ratio = y / step r = int(r1 * (1 - ratio) + r2 * ratio) g = int(g1 * (1 - ratio) + g2 * ratio) b = int(b1 * (1 - ratio) + b2 * ratio) color = f'#{r:02x}{g:02x}{b:02x}' y_pos = i * step + y if y_pos < height: draw.line([(0, y_pos), (width, y_pos)], fill=color) return gradient def wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int) -> List[str]: """Quebra o texto em múltiplas linhas sem cortar palavras""" words = text.split() lines = [] current_line = [] for word in words: test_line = ' '.join(current_line + [word]) test_width, _ = get_text_dimensions(test_line, font) if test_width <= max_width: current_line.append(word) else: if current_line: lines.append(' '.join(current_line)) current_line = [word] if current_line: lines.append(' '.join(current_line)) return lines def add_3d_effect(draw, text: str, position: Tuple[int, int], font: ImageFont.FreeTypeFont, text_color: str, shadow_color: str = "#000000", offset: int = 3): """Adiciona efeito 3D ao texto""" x, y = position # Sombra (para efeito 3D) - múltiplas camadas para efeito mais pronunciado for offset_x, offset_y in [(offset, offset), (-offset, -offset), (offset, -offset), (-offset, offset), (offset, 0), (0, offset), (-offset, 0), (0, -offset)]: draw.text((x + offset_x, y + offset_y), text, font=font, fill=shadow_color) # Texto principal draw.text(position, text, font=font, fill=text_color) def process_thumbnail( background_image: Image.Image, title: str, channel_name: str, palette_choice: str, title_font_size: int, channel_font_size: int, title_position: str, channel_position: str ) -> Image.Image: """Processa e cria a thumbnail""" # Redimensiona a imagem de fundo bg_img = background_image.copy() bg_img = bg_img.resize(THUMB_SIZE, Image.Resampling.LANCZOS) # Aplica um leve blur no fundo para destacar o texto bg_img = bg_img.filter(ImageFilter.GaussianBlur(radius=1)) # Cria uma camada para os elementos overlay = Image.new('RGBA', THUMB_SIZE, (0, 0, 0, 0)) draw = ImageDraw.Draw(overlay) # Obtém a paleta de cores palette = COLOR_PALETTES.get(palette_choice, list(COLOR_PALETTES.values())[0]) # Carrega as fontes usando a nova função title_font = load_font("title", title_font_size) channel_font = load_font("channel", channel_font_size) # Calcula posição do título max_title_width = THUMB_SIZE[0] - 2 * MARGIN title_lines = wrap_text(title, title_font, max_title_width) # Calcula altura total do título line_height = int(title_font_size * 1.3) total_title_height = len(title_lines) * line_height # Define posição Y do título if title_position == "Topo": title_y = MARGIN elif title_position == "Centro": title_y = (THUMB_SIZE[1] - total_title_height) // 2 else: # "Base" title_y = THUMB_SIZE[1] - total_title_height - 150 # Altura do fundo do título title_bg_padding = 30 title_bg_height = total_title_height + title_bg_padding * 2 title_bg_y = max(10, title_y - title_bg_padding) # Cria gradiente para o fundo do título title_bg = create_gradient_bg( (THUMB_SIZE[0], title_bg_height), palette["title_bg"] ) # Aplica transparência ao fundo do título title_bg_mask = Image.new('L', (THUMB_SIZE[0], title_bg_height), 180) title_bg.putalpha(title_bg_mask) # Coloca o fundo do título overlay.paste(title_bg, (0, title_bg_y), title_bg) # Desenha cada linha do título com efeito 3D for i, line in enumerate(title_lines): line_width, line_height_actual = get_text_dimensions(line, title_font) x = (THUMB_SIZE[0] - line_width) // 2 y = title_y + (i * line_height) add_3d_effect( draw, line, (x, y), title_font, text_color=palette["text"], shadow_color=palette.get("shadow", "#000000"), offset=4 ) # Processa o nome do canal (botão) max_channel_width = THUMB_SIZE[0] // 2 channel_lines = wrap_text(channel_name, channel_font, max_channel_width) # Se tiver mais de 2 linhas, pega apenas as primeiras 2 if len(channel_lines) > 2: channel_lines = channel_lines[:2] channel_text = "\n".join(channel_lines) else: channel_text = "\n".join(channel_lines) # Calcula dimensões do botão do canal channel_lines_list = channel_text.split("\n") max_line_width = 0 total_channel_height = 0 for line in channel_lines_list: line_width, line_height = get_text_dimensions(line, channel_font) max_line_width = max(max_line_width, line_width) total_channel_height += line_height channel_padding = 25 button_width = max_line_width + 2 * channel_padding button_height = total_channel_height + 2 * channel_padding # Ajusta tamanho máximo do botão button_width = min(button_width, THUMB_SIZE[0] - 2 * MARGIN) # Define posição do botão do canal if channel_position == "Acima do Título": button_y = max(20, title_bg_y - button_height - 20) elif channel_position == "Abaixo do Título": button_y = min(THUMB_SIZE[1] - button_height - 20, title_bg_y + title_bg_height + 30) else: # "Centro Inferior" button_y = THUMB_SIZE[1] - button_height - MARGIN button_x = (THUMB_SIZE[0] - button_width) // 2 # Cria gradiente para o botão do canal channel_bg = create_gradient_bg( (button_width, button_height), palette["channel_bg"] ) # Cria máscara para bordas arredondadas mask = Image.new('L', (button_width, button_height), 0) mask_draw = ImageDraw.Draw(mask) # Desenha retângulo arredondado radius = min(25, button_height // 4) mask_draw.rounded_rectangle( [(0, 0), (button_width - 1, button_height - 1)], radius=radius, fill=255 ) channel_bg.putalpha(mask) # Coloca o botão do canal overlay.paste(channel_bg, (button_x, button_y), channel_bg) # Desenha o texto do canal (suporte a múltiplas linhas) text_y_offset = channel_padding for line in channel_lines_list: line_width, line_height = get_text_dimensions(line, channel_font) line_x = button_x + (button_width - line_width) // 2 line_y = button_y + text_y_offset draw.text( (line_x, line_y), line, font=channel_font, fill=palette["text"], stroke_width=2, stroke_fill=palette.get("shadow", "#000000") ) text_y_offset += line_height # Combina tudo result = Image.alpha_composite(bg_img.convert('RGBA'), overlay) return result.convert('RGB') def optimize_image_size(image: Image.Image, max_size: int = MAX_FILE_SIZE) -> bytes: """Otimiza o tamanho da imagem mantendo a qualidade""" output = BytesIO() quality = QUALITY # Loop para encontrar a qualidade ideal while quality > 20: output.seek(0) output.truncate(0) # Salva com otimização image.save(output, format='JPEG', quality=quality, optimize=True, subsampling=0) if len(output.getvalue()) <= max_size: break quality -= 5 return output.getvalue() def create_thumbnail( background_image, title: str, channel_name: str, palette_choice: str, title_font_size: int, channel_font_size: int, title_position: str, channel_position: str ) -> Tuple[Image.Image, str]: """Função principal para criar thumbnail""" # Verifica se há imagem de fundo if background_image is None: # Cria uma imagem de fundo padrão background_image = Image.new('RGB', THUMB_SIZE, '#1A1A1A') draw = ImageDraw.Draw(background_image) # Adiciona gradiente for i in range(THUMB_SIZE[1]): ratio = i / THUMB_SIZE[1] r = int(26 * (1 - ratio) + 100 * ratio) g = int(26 * (1 - ratio) + 100 * ratio) b = int(46 * (1 - ratio) + 200 * ratio) draw.line([(0, i), (THUMB_SIZE[0], i)], fill=(r, g, b)) else: background_image = Image.fromarray(background_image.astype('uint8')) # Processa a thumbnail thumbnail = process_thumbnail( background_image, title, channel_name, palette_choice, title_font_size, channel_font_size, title_position, channel_position ) # Otimiza o tamanho optimized_data = optimize_image_size(thumbnail) # Salva temporariamente para download temp_path = tempfile.mktemp(suffix='.jpg') with open(temp_path, 'wb') as f: f.write(optimized_data) return thumbnail, temp_path # Interface Gradio def create_interface(): with gr.Blocks(title="Gerador de Thumbnails YouTube Clickbait", theme=gr.themes.Soft(primary_hue="blue", secondary_hue="purple")) as demo: gr.HTML(""" """) gr.Markdown('