Otimização eficiente de JPEG em PHP com jpegoptim
Execute o jpegoptim em uma cópia temporária, verifique o status de saída e publique o resultado somente quando o nome de saída escolhido ainda estiver disponível. Este exemplo de CLI em PHP mantém o JPEG de origem intacto e se recusa a substituir uma saída existente, inclusive quando a entrada está corrompida.
Escolha o que alterar
A otimização de JPEG sem perdas reorganiza os dados comprimidos sem uma nova codificação que reduza a qualidade. Isso não significa que o arquivo resultante tenha bytes idênticos. O script abaixo oferece três modos distintos:
| Modo | Opções do jpegoptim | Alteração pretendida |
|---|---|---|
lossless (padrão) | --strip-none | Otimizar a compressão mantendo os metadados. |
strip | --strip-all --force | Remover metadados sem reduzir a qualidade do JPEG. |
quality80 | --strip-none --max=80 | Permitir redução de qualidade mantendo os metadados. |
O manual do jpegoptim descreve
--max como um teto de qualidade, não como uma redução percentual do tamanho do arquivo. Um JPEG
que já esteja abaixo dessa qualidade pode seguir o caminho sem perdas. Evite --max e
--size quando preservar a qualidade da imagem for o requisito.
Os metadados exigem uma decisão à parte: remover o EXIF pode fazer perder a orientação, e remover
perfis ICC pode alterar a renderização de cores. Use strip somente quando essas alterações forem
aceitáveis. Ele força uma regravação para que a remoção de metadados não seja ignorada por falta de
economia de tamanho. Mesmo --strip-all pode deixar marcadores JFIF ou Adobe gerados pelo codificador;
--strip-none também permite que esses marcadores sejam regenerados. Nenhum dos modos promete preservar os
metadados byte a byte.
Instale as ferramentas de CLI
Use um diretório Linux local que você controle, com espaço para uma cópia temporária e um sistema
de arquivos que suporte hard links. Você precisa do PHP CLI com proc_open() habilitado e do executável
do jpegoptim. No Ubuntu 24.04, instale ambos pelo APT:
sudo apt-get update &&
sudo apt-get install -y php-cli jpegoptim &&
php --version &&
jpegoptim --version
O Ubuntu 24.04 empacota o jpegoptim 1.4.7, que suporta as opções usadas aqui. Este fluxo de trabalho foi testado com PHP 8.3 e jpegoptim 1.4.7, e com PHP 8.5.10 e jpegoptim 1.5.6. Ele não requer Composer, GD nem um servidor web.
Crie um novo diretório de trabalho:
mkdir jpeg-demo && cd jpeg-demo
Se esse comando falhar, pare e escolha um nome de diretório não utilizado. Coloque neste diretório
um JPEG de sua propriedade com o nome input.jpg. Salve o script a seguir ao lado dele como
optimize-jpeg.php.
Gere uma saída separada a partir do PHP
O diretório de saída já deve existir. O script verifica colisões antes de fazer qualquer trabalho e,
em seguida, usa o link() do PHP para dar ao arquivo
temporário concluído o seu nome final. Essa operação também recusa uma saída criada enquanto o
jpegoptim estava em execução. A limpeza remove apenas o nome temporário.
<?php
declare(strict_types=1);
function main(array $args): void
{
if (count($args) < 3 || count($args) > 4) {
throw new InvalidArgumentException(
'Usage: php optimize-jpeg.php INPUT OUTPUT [lossless|strip|quality80]'
);
}
$options = match ($args[3] ?? 'lossless') {
'lossless' => ['--strip-none'],
'strip' => ['--strip-all', '--force'],
'quality80' => ['--strip-none', '--max=80'],
default => throw new InvalidArgumentException('Unknown optimization mode.'),
};
$binary = getenv('JPEGOPTIM_BINARY') ?: '/usr/bin/jpegoptim';
if (!str_starts_with($binary, '/') || !is_file($binary) || !is_executable($binary)) {
throw new RuntimeException('Set JPEGOPTIM_BINARY to an executable absolute path.');
}
$input = realpath($args[1]);
if ($input === false || !is_file($input) || !is_readable($input)) {
throw new RuntimeException('Input must be a readable local file.');
}
$directory = realpath(dirname($args[2]));
if ($directory === false || !is_dir($directory) || !is_writable($directory)) {
throw new RuntimeException('Output directory must exist and be writable.');
}
$output = $directory . '/' . basename($args[2]);
if (file_exists($output) || is_link($output)) {
throw new RuntimeException('Output already exists; choose a new filename.');
}
$before = filesize($input);
if ($before === false || $before === 0) {
throw new RuntimeException('Input is empty or its size cannot be read.');
}
$temporary = tempnam($directory, '.jpegoptim-');
if ($temporary === false) {
throw new RuntimeException('Cannot create a temporary file.');
}
try {
// tempnam can fall back to the system temp directory; keep publication on one filesystem.
if (dirname($temporary) !== $directory || !copy($input, $temporary)) {
throw new RuntimeException('Cannot create the working copy in the output directory.');
}
$process = proc_open(
[$binary, '--nofix', '--quiet', ...$options, '--', $temporary],
[0 => ['file', '/dev/null', 'r'], 1 => STDERR, 2 => STDERR],
$pipes
);
if (!is_resource($process)) {
throw new RuntimeException('Cannot start jpegoptim.');
}
$status = proc_close($process);
if ($status !== 0) {
throw new RuntimeException("jpegoptim failed (exit $status); no output published.");
}
clearstatcache(true, $temporary);
$after = filesize($temporary);
if ($after === false || $after === 0) {
throw new RuntimeException('jpegoptim did not leave a nonempty output.');
}
if (!link($temporary, $output)) {
throw new RuntimeException('Cannot publish output; check for a collision or filesystem error.');
}
} finally {
unlink($temporary);
}
printf("Created %s: %d -> %d bytes; saved %d bytes\n", $output, $before, $after, $before - $after);
}
try {
main($argv);
} catch (Throwable $error) {
fwrite(STDERR, 'Error: ' . $error->getMessage() . "\n");
exit(1);
}
proc_open() aceita um array de argumentos,
então os caminhos não se tornam comandos de shell. O jpegoptim recebe apenas o arquivo temporário,
nunca a origem nem o destino final. A opção --nofix rejeita avisos de descompressão além de erros,
em vez de tentar reparar um JPEG danificado. Uma verificação de MIME por si só não garantiria que a
imagem inteira pode ser decodificada.
Execute o modo padrão sem perdas:
php optimize-jpeg.php input.jpg lossless.jpg
O script usa /usr/bin/jpegoptim, o local do pacote do Ubuntu. Se você o instalou em outro lugar,
defina JPEGOPTIM_BINARY com o caminho absoluto dele na invocação. Não aceite essa configuração nem flags
arbitrárias do otimizador a partir de uma requisição HTTP.
Uma execução bem-sucedida termina com status zero e imprime o caminho de saída, as contagens de
bytes da entrada e da saída e os bytes economizados. Economia zero é válida: você ainda obtém uma
saída separada. Uma economia negativa significa que a saída cresceu, o que o modo forçado strip
permite. Executar o comando novamente com o mesmo nome de saída falha sem alterar esse arquivo.
Usar o nome da entrada como saída também falha.
Uma entrada corrompida, um executável ausente ou um processo do jpegoptim com falha produz um status de saída diferente de zero. Nenhuma saída final é publicada nessas falhas, e a cópia temporária é removida durante o tratamento normal de exceções. As saídas existentes e a origem permanecem no lugar.
Medição dos resultados de compressão
Compare os outros modos usando novos nomes de saída:
php optimize-jpeg.php input.jpg stripped.jpg strip &&
php optimize-jpeg.php input.jpg quality80.jpg quality80
Registre as contagens de bytes junto com as versões instaladas e o modo. Compare fotos representativas, miniaturas pequenas e imagens já otimizadas por outra ferramenta; a economia em uma não prevê a economia em outra. O script não redimensiona imagens nem garante um tamanho de arquivo alvo.
Abra as saídas em um visualizador de imagens. Para quality80, inspecione detalhes finos, gradientes
e texto em tamanho real antes de aceitar a alteração de qualidade. Para strip, verifique a
orientação e as cores em relação ao original. Se você automatizar uma verificação sem perdas,
decodifique a origem e a saída com o mesmo decodificador e compare os buffers de pixels e as
dimensões; comparar hashes dos arquivos JPEG testa, em vez disso, a identidade dos bytes.
Chame o script a partir de um worker
Para uma tarefa em lote ou uma aplicação Laravel, mantenha o original e reserve um destino novo para cada imagem. Execute esta operação de CLI em um worker e verifique o status de saída antes de registrar uma saída como disponível. Uma tarefa com falha deve manter a origem para diagnóstico ou nova tentativa. Um wrapper do Composer ainda precisa do executável nativo e de uma política explícita para falhas e colisões de destino.
Considerações de segurança
Este exemplo processa arquivos locais em diretórios controlados pela mesma conta confiável. Ele não
é um endpoint de upload nem uma sandbox para arquivos hostis. Ele mantém as permissões de saída
privadas (o arquivo temporário começa com o modo 0600);
configure deliberadamente quaisquer permissões posteriores para servir os arquivos pela web.
Um serviço de upload também precisa de limites de bytes e de pixels, processamento isolado e um
prazo no worker que encerre o processo externo. O set_time_limit()
do PHP não limita o tempo gasto em operações externas no Linux. Este pequeno script de CLI não tem
timeout de processo, e um encerramento forçado pode deixar arquivos temporários para trás. Mantenha
o diretório de trabalho dele privado e trate a limpeza de tarefas interrompidas no worker
responsável por esses arquivos.
