Daniel251's picture
Update app.py
31ddb05 verified
Raw
History Blame Contribute Delete
22 kB
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
)