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('
🎬 GERADOR DE THUMBNAILS YOUTUBE
') gr.Markdown('
Crie thumbnails modernas e clickbait para seus vídeos!
') with gr.Row(equal_height=True): with gr.Column(scale=1, min_width=500): gr.Markdown("### ⚙️ CONFIGURAÇÕES") # Entradas background_image = gr.Image( label="📷 Imagem de Fundo", type="numpy", height=250 ) title_input = gr.Textbox( label="📝 Título (Clickbait)", value="VOCÊ NÃO VAI ACREDITAR NO QUE ACONTECEU!", max_lines=3, placeholder="Digite um título impactante e chamativo..." ) channel_input = gr.Textbox( label="📢 Nome do Canal", value="INCRÍVEL EXPERIÊNCIA", placeholder="Nome do seu canal..." ) palette_choice = gr.Dropdown( label="🎨 Paleta de Cores", choices=list(COLOR_PALETTES.keys()), value="Neon Cyberpunk", info="Escolha uma combinação de cores moderna" ) with gr.Row(): title_font_size = gr.Slider( label="Tamanho da Fonte do Título", minimum=40, maximum=100, value=65, step=5 ) channel_font_size = gr.Slider( label="Tamanho da Fonte do Canal", minimum=20, maximum=60, value=35, step=5 ) title_position = gr.Radio( label="📌 Posição do Título", choices=["Topo", "Centro", "Base"], value="Centro" ) channel_position = gr.Radio( label="📍 Posição do Botão do Canal", choices=["Acima do Título", "Abaixo do Título", "Centro Inferior"], value="Abaixo do Título" ) generate_btn = gr.Button( "🎨 GERAR THUMBNAIL", variant="primary", size="lg" ) # Dicas with gr.Accordion("💡 DICAS PARA THUMBNAILS CLICKBAIT", open=False): gr.Markdown(""" - **Use palavras fortes**: "INCRÍVEL", "GRÁTIS", "SECRETO", "VOCÊ NÃO VAI ACREDITAR" - **Cores contrastantes**: Garanta que o texto seja sempre legível - **Emoções**: Mostre rostos com expressões exageradas - **Setas e elementos**: Direcione o olhar do espectador - **Cores quentes**: Laranja, vermelho e amarelo chamam mais atenção - **Texto grande**: Garanta legibilidade mesmo em telas pequenas """) with gr.Column(scale=1, min_width=500): gr.Markdown("### 👁️ PRÉ-VISUALIZAÇÃO") # Output image_output = gr.Image( label="Thumbnail Gerada", type="pil", height=400 ) # Download download_output = gr.File( label="📥 Download da Thumbnail", file_count="single" ) # Informações with gr.Accordion("📊 INFORMAÇÕES TÉCNICAS", open=True): gr.Markdown(f""" - **Resolução:** {THUMB_SIZE[0]}x{THUMB_SIZE[1]} pixels (YouTube padrão) - **Tamanho máximo:** {MAX_FILE_SIZE/1024/1024:.1f} MB - **Formato:** JPEG otimizado - **Margens:** {MARGIN}px - **Qualidade:** Ajustável automaticamente - **Fontes:** Montserrat (Título) e Poppins (Canal) """) # Exemplos gr.Markdown("### 🚀 EXEMPLOS PRONTOS PARA TESTAR") gr.Examples( examples=[ [ None, "VOCÊ NÃO VAI ACREDITAR NO QUE ACONTECEU!", "INCRÍVEL EXPERIÊNCIA", "Neon Cyberpunk", 70, 40, "Centro", "Abaixo do Título" ], [ None, "ISSO VAI MUDAR SUA VIDA COMPLETAMENTE!", "DESCUBRA AGORA", "Gradient Sunset", 65, 35, "Topo", "Centro Inferior" ], [ None, "O QUE ELES ESCONDEM DE VOCÊ?", "VERDADE REVELADA", "Dark Modern", 75, 45, "Base", "Abaixo do Título" ] ], inputs=[ background_image, title_input, channel_input, palette_choice, title_font_size, channel_font_size, title_position, channel_position ], outputs=[image_output, download_output], fn=create_thumbnail, label="Clique em qualquer exemplo para testar!", cache_examples=False ) # Ação do botão generate_btn.click( fn=create_thumbnail, inputs=[ background_image, title_input, channel_input, palette_choice, title_font_size, channel_font_size, title_position, channel_position ], outputs=[image_output, download_output] ) return demo if __name__ == "__main__": demo = create_interface() demo.launch( server_name="0.0.0.0", server_port=7860, share=False )