Spaces:
Sleeping
Sleeping
| 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(""" | |
| <style> | |
| .gradio-container { | |
| max-width: 1400px !important; | |
| margin: 0 auto !important; | |
| } | |
| .title { | |
| text-align: center; | |
| background: linear-gradient(90deg, #FF00FF, #00FFFF, #FFFF00); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| font-weight: 900; | |
| font-size: 2.5em; | |
| margin-bottom: 10px; | |
| } | |
| .subtitle { | |
| text-align: center; | |
| color: #666; | |
| margin-bottom: 30px; | |
| font-size: 1.1em; | |
| } | |
| </style> | |
| """) | |
| gr.Markdown('<div class="title">🎬 GERADOR DE THUMBNAILS YOUTUBE</div>') | |
| gr.Markdown('<div class="subtitle">Crie thumbnails modernas e clickbait para seus vídeos!</div>') | |
| 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 | |
| ) |