thumbnail

Otimizei Imagens do meu Blog Laravel com WebP, Filament e Docker

Yuri do Monte Yuri do Monte | 8 min de leitura
há 4 horas


Otimizei as imagens do meu Blog Laravel com WebP, Filament e Docker

Fala dev, beleza? Se liga como identifiquei a lentidão no meu blog e como corrigi esse B.O.!

Sabe quando você entra na home do blog e a página parece que tá carregando numa conexão de internet discada? 

Pois é, fui investigar e descobri que o grande vilão era um clássico: imagens pesadas. 

A home listava vários posts com thumbnails, e cada imagem gigante adicionava tempo de carregamento e sugava a banda do servidor. A experiência do usuário tava simplesmente indo pro ralo.

Até tinha uma tentativa de otimização no projeto, mas era aquele famoso "funciona, mas nem tanto". Algumas thumbnails continuavam com vários megabytes. O pior? As versões menores eram geradas, mas mantinham a extensão original. Ou seja, um PNG gigante virava um PNG menor, quando um WebP seria infinitamente mais leve. 

Pra piorar, o container PHP até tinha a biblioteca GD instalada, mas sem suporte real a WebP, e as imagens servidas pelo diretório de storage não tinham cache configurado no Nginx.

Aí resolvi colocar a mão na massa e montar uma arquitetura de respeito.

A Solução: ImageOptimizationService

A primeira coisa foi criar um serviço dedicado, o meu  famoso ImageOptimizationService. 

A ideia era simples: receber o caminho de uma imagem, criar versões menores e salvar tudo em WebP, configurei três contextos de tamanho (small, medium e large) para thumbnails, imagens de destaque e imagens completas.


private const SIZES = [
    'thumbnail' => ['small' => 300, 'medium' => 600, 'large' => 900],
    'featured'  => ['small' => 400, 'medium' => 800, 'large' => 1200],
    'full'      => ['small' => 600, 'medium' => 1200, 'large' => 1800],
];




A função principal localiza a imagem e gera essas versões, retornando os metadados pra eu salvar bonitinho no banco de dados:


public function optimizeOnUpload(string $filePath, string $context = 'thumbnail'): array
{
    $disk     = Storage::disk('public');
    $filePath = ltrim($filePath, '/');
    $fullPath = $disk->path($filePath);

    if (!file_exists($fullPath)) {
        throw new \Exception("Arquivo não encontrado: {$filePath}");
    }

    try {
        $sizes           = self::SIZES[$context] ?? self::SIZES['thumbnail'];
        $optimizedImages = [];

        foreach ($sizes as $sizeKey => $width) {
            $optimizedImages[$sizeKey] = $this->createOptimizedVersion(
                $fullPath,
                $filePath,
                $width,
                $sizeKey
            );
        }

        return [
            'original'  => $filePath,
            'optimized' => $optimizedImages,
            'context'   => $context,
            'sizes'     => $sizes,
        ];
    } catch (\Exception $e) {
        \Log::error('Erro ao otimizar imagem', [
            'file'  => $filePath,
            'error' => $e->getMessage(),
        ]);
        throw $e;
    }
}




Intervention Image + ImageMagick

Para a conversão em si, usei o pacote Intervention Image com o driver GD. O esquema reduz a largura sem distorcer e converte para WebP com qualidade 82, que é o ponto ideal entre tamanho do arquivo e resolução:

private function createWithIntervention(string $source, string $destination, int $width): bool
{
    try {
        $manager = new ImageManager(new Driver());

        $manager
            ->read($source)
            ->scaleDown(width: $width) // Redimensiona apenas se a imagem for maior que $width
            ->toWebp(quality: 82)
            ->save($destination);

        return file_exists($destination) && filesize($destination) > 0;

    } catch (\Throwable $e) {
        \Log::warning('Intervention não conseguiu otimizar imagem', [
            'source' => $source,
            'error'  => $e->getMessage(),
        ]);

        return false;
    }
}




E se o Intervention der ruim? Sem problemas, deixei o ImageMagick configurado como fallback, rodando direto no terminal com parâmetros que limpam metadados desnecessários:


private function createWithImageMagick(string $source, string $destination, int $width): bool
{
    $binary = trim((string) shell_exec('command -v magick || command -v convert'));

    if ($binary === '') {
        return false;
    }

    $command = sprintf(
        '%s %s -auto-orient -strip -resize %dx -quality 82 %s 2>&1',
        escapeshellcmd($binary),
        escapeshellarg($source),
        $width,
        escapeshellarg($destination)
    );

    shell_exec($command);

    return file_exists($destination) && filesize($destination) > 0;
}




Evitando Reprocessamento

Outro ponto crucial foi evitar reprocessamento. Criei verificações para garantir que o sistema não tente otimizar o que já tá otimizado, evitando bizarrices como gerar um arquivo logo-small-small.webp:



public function hasOptimizedVersions(string $imagePath, string $context = 'thumbnail'): bool
{
    if ($imagePath === '' || str_contains($imagePath, 'http' )) {
        return false;
    }

    $disk     = Storage::disk('public');
    $sizes    = self::SIZES[$context] ?? self::SIZES['thumbnail'];
    $pathInfo = pathinfo(ltrim($imagePath, '/'));
    $directory = $pathInfo['dirname'];
    $filename  = $pathInfo['filename'];

    foreach (array_keys($sizes) as $sizeKey) {
        if (!$disk->exists("{$directory}/{$filename}-{$sizeKey}.webp")) {
            return false;
        }
    }

    return true;
}


public function isGeneratedVersion(string $file): bool
{
    return (bool) preg_match('/-(small|medium|large)\.[a-z0-9]+$/i', $file);
}




Gerenciamento via Filament

Como eu gerencio isso no dia a dia? 

Criei uma tela lindona no painel do Filament!..... Fica lá em "Ferramentas → Otimizar Imagens", mostrando contadores do que já foi processado, posso disparar a otimização com um clique, sem precisar abrir o terminal pra rodar comando Artisan.

O método optimizeDirectory() faz o trabalho pesado, iterando por todos os arquivos de um diretório e processando apenas os que precisam:


public function optimizeDirectory(string $directory, string $context = 'thumbnail', bool $force = false): int
{
    $disk      = Storage::disk('public');
    $optimized = 0;

    foreach ($disk->files($directory) as $file) {
        if (!$this->isOptimizableImage($file) || $this->isGeneratedVersion($file)) {
            continue;
        }

        if (!$force && $this->hasOptimizedVersions($file, $context)) {
            continue;
        }

        try {
            $this->optimizeOnUpload($file, $context);
            $optimized++;
        } catch (\Throwable $e) {
            \Log::error('Erro ao otimizar imagem do diretório', [
                'file'  => $file,
                'error' => $e->getMessage(),
            ]);
        }
    }

    return $optimized;
}




Gerando Srcset para o HTML

Pra fechar com chave de ouro, criei métodos pra gerar o srcset e sizes direto no HTML, permitindo que o navegador escolha a melhor versão da imagem:


public function generateSrcset(string $imagePath, string $context = 'thumbnail'): string
{
    if ($imagePath === '' || str_contains($imagePath, 'http' )) {
        return '';
    }

    $disk      = Storage::disk('public');
    $sizes     = self::SIZES[$context] ?? self::SIZES['thumbnail'];
    $imagePath = ltrim($imagePath, '/');
    $pathInfo  = pathinfo($imagePath);
    $directory = $pathInfo['dirname'];
    $filename  = $pathInfo['filename'];

    $srcsetParts = [];

    foreach ($sizes as $sizeKey => $width) {
        $optimizedPath = "{$directory}/{$filename}-{$sizeKey}.webp";

        if ($disk->exists($optimizedPath)) {
            $srcsetParts[] = $disk->url($optimizedPath) . " {$width}w";
        }
    }

    return implode(', ', $srcsetParts);
}


public function generateSizes(string $context = 'thumbnail'): string
{
    return match ($context) {
        'thumbnail' => '(max-width: 640px) 300px, (max-width: 1024px) 600px, 900px',
        'featured'  => '(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px',
        'full'      => '(max-width: 640px) 600px, (max-width: 1024px) 1200px, 1800px',
        default     => '100vw',
    };
}




Isso permite usar no Blade assim:


<img 
    src="{{ $imageOptimizationService->getOptimizedUrl($post->image, 'medium') }}"
    srcset="{{ $imageOptimizationService->generateSrcset($post->image, 'featured') }}"
    sizes="{{ $imageOptimizationService->generateSizes('featured') }}"
    alt="Post thumbnail"
/>




Docker com Suporte Real a WebP

Claro que o ambiente também precisava de um trato. Fui no Dockerfile e instalei as libs corretas, recompilando a extensão GD do PHP com suporte oficial a WebP:


RUN apt-get install -y libjpeg62-turbo-dev libfreetype6-dev libwebp-dev imagemagick

RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
    && docker-php-ext-install gd




E pra fechar com chave de ouro, configurei um cache explícito no Nginx para a pasta de storage, deixando as imagens cacheadas no navegador do usuário por trinta dias:

location /storage/ {
    alias /var/www/app/public/storage/;
    try_files $uri $uri/ =404;
    expires 30d;
    add_header Cache-Control "public, max-age=2592000, immutable";
    access_log off;
}


Link do código:  https://github.com/yuri-spm/ImageOptimizationService

O Resultado

Imagens que antes pesavam megabytes caíram para algumas dezenas de kilobytes. O site agora voa no mobile, a banda do servidor agradece, e eu não dependo mais de otimização manual pra cada post novo. A estrutura ficou reutilizável e escalável.

E aí, como você costuma lidar com o peso das imagens nos seus projetos? 

Já teve que fazer uma refatoração parecida? 

Deixa aí nos comentários!



Comentários

Realize login para comentar neste post
Este post não possui comentários