Optimización eficiente de JPEG en PHP con jpegoptim
Ejecuta jpegoptim sobre una copia temporal, comprueba su estado de salida y publica el resultado solo cuando el nombre de salida elegido siga disponible. Este ejemplo de PHP CLI deja intacto el JPEG de origen y se niega a reemplazar una salida existente, incluso cuando la entrada está dañada.
Elige qué cambiar
La optimización de JPEG sin pérdidas reorganiza los datos comprimidos sin otra codificación que reduzca la calidad. No significa que el archivo resultante tenga bytes idénticos. El siguiente script ofrece tres modos independientes:
| Modo | Opciones de jpegoptim | Cambio previsto |
|---|---|---|
lossless (predeterminado) | --strip-none | Optimizar la compresión conservando los metadatos. |
strip | --strip-all --force | Eliminar los metadatos sin reducir la calidad del JPEG. |
quality80 | --strip-none --max=80 | Permitir una reducción de calidad conservando los metadatos. |
El manual de jpegoptim describe
--max como un límite máximo de calidad, no como una reducción porcentual del
tamaño del archivo. Un JPEG cuya calidad ya esté por debajo de ese límite puede seguir la vía sin
pérdidas. Evita --max y --size cuando el requisito sea
preservar la calidad de la imagen.
Los metadatos requieren una decisión aparte: eliminar EXIF puede hacer que se pierda la orientación,
y eliminar los perfiles ICC puede cambiar la reproducción del color. Usa
strip solo cuando esos cambios sean aceptables. Fuerza una reescritura para
que no se omita la eliminación de metadatos por falta de ahorro de tamaño. Incluso
--strip-all puede dejar marcadores JFIF o Adobe generados por el codificador;
--strip-none también permite que esos marcadores se regeneren.
Ninguno de los modos promete conservar los metadatos byte por byte.
Instala las herramientas de CLI
Usa un directorio local de Linux que controles, con espacio para una copia temporal y un sistema de
archivos que admita enlaces duros. Necesitas PHP CLI con proc_open() habilitado y
el ejecutable de jpegoptim. En Ubuntu 24.04, instala ambos mediante APT:
sudo apt-get update &&
sudo apt-get install -y php-cli jpegoptim &&
php --version &&
jpegoptim --version
Ubuntu 24.04 incluye el paquete jpegoptim 1.4.7, que admite las opciones utilizadas aquí. Este flujo de trabajo se probó con PHP 8.3 y jpegoptim 1.4.7, y con PHP 8.5.10 y jpegoptim 1.5.6. No requiere Composer, GD ni un servidor web.
Crea un directorio de trabajo nuevo:
mkdir jpeg-demo && cd jpeg-demo
Si ese comando falla, detente y elige un nombre de directorio que no esté en uso. Coloca un JPEG de tu
propiedad en este directorio con el nombre input.jpg. Guarda el siguiente script
en el mismo directorio con el nombre optimize-jpeg.php.
Escribe una salida independiente desde PHP
El directorio de salida ya debe existir. El script comprueba si hay colisiones antes de empezar y
luego usa link() de PHP para dar al archivo
temporal completado su nombre definitivo. Esa operación también rechaza una salida creada mientras
jpegoptim se estaba ejecutando. La limpieza elimina únicamente el nombre temporal.
<?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() acepta un array de argumentos,
por lo que las rutas no se convierten en comandos del shell. jpegoptim recibe únicamente el archivo
temporal, nunca el origen ni el destino final. La opción --nofix rechaza tanto
las advertencias de descompresión como los errores, en lugar de intentar reparar un JPEG dañado.
Una comprobación MIME por sí sola no permitiría determinar si se puede decodificar toda la imagen.
Ejecuta el modo sin pérdidas predeterminado:
php optimize-jpeg.php input.jpg lossless.jpg
El script usa /usr/bin/jpegoptim, la ubicación del paquete de Ubuntu. Si lo instalaste
en otro lugar, establece JPEGOPTIM_BINARY en su ruta absoluta para la invocación.
No aceptes ese ajuste ni opciones arbitrarias del optimizador desde una solicitud HTTP.
Una ejecución correcta termina con un estado de salida de cero e imprime la ruta de salida, los
recuentos de bytes de entrada y salida, y los bytes ahorrados. Un ahorro de cero es válido: de todos
modos obtienes una salida independiente. Un ahorro negativo significa que la salida aumentó de
tamaño, lo que permite el modo forzado strip. Volver a ejecutar el comando
con el mismo nombre de salida falla sin modificar ese archivo. Usar el nombre de entrada como
salida también falla.
Una entrada dañada, un ejecutable ausente o un proceso de jpegoptim fallido produce un estado de salida distinto de cero. Ante esos fallos no se publica ninguna salida final, y la copia temporal se elimina durante el manejo normal de excepciones. Las salidas existentes y el origen permanecen en su lugar.
Medición de los resultados de compresión
Compara los otros modos usando nombres de salida nuevos:
php optimize-jpeg.php input.jpg stripped.jpg strip &&
php optimize-jpeg.php input.jpg quality80.jpg quality80
Registra los recuentos de bytes junto con las versiones instaladas y el modo utilizado. Compara fotografías representativas, miniaturas pequeñas e imágenes ya optimizadas por otra herramienta; un ahorro en una no predice un ahorro en otra. El script no cambia las dimensiones de las imágenes ni garantiza un tamaño de archivo objetivo.
Abre las salidas en un visor de imágenes. Para quality80, inspecciona los
detalles finos, los degradados y el texto a tamaño completo antes de aceptar el cambio de calidad.
Para strip, compara la orientación y el color con el original. Si automatizas
una comprobación de ausencia de pérdidas, decodifica el origen y la salida con el mismo decodificador
y compara los búferes de píxeles y las dimensiones; comparar los hashes de los archivos JPEG, en
cambio, comprueba si sus bytes son idénticos.
Llama al script desde un proceso de trabajo
Para una tarea por lotes o una aplicación Laravel, conserva el original y asigna un destino nuevo a cada imagen. Ejecuta esta operación de CLI en un proceso de trabajo y comprueba su estado de salida antes de registrar una salida como disponible. Una tarea fallida debería conservar el origen para diagnosticar el problema o reintentarla. Una capa de integración de Composer sigue necesitando el ejecutable nativo y una política explícita para los fallos y las colisiones de destinos.
Consideraciones de seguridad
Este ejemplo procesa archivos locales en directorios controlados por la misma cuenta de confianza.
No es un endpoint de subida ni un entorno aislado para archivos hostiles. Mantiene privados los
permisos de salida (el archivo temporal comienza con el
modo 0600);
configura deliberadamente cualquier permiso posterior para servirlo en la web.
Un servicio de subida también necesita límites de bytes y píxeles, procesamiento aislado y un plazo
máximo para el proceso de trabajo que termine el proceso externo.
set_time_limit() de PHP no limita el tiempo dedicado a
operaciones externas en Linux. Este pequeño script de CLI no tiene un tiempo de espera máximo para
el proceso, y una terminación forzada puede dejar archivos temporales. Mantén privado su directorio
de trabajo y gestiona la limpieza de tareas interrumpidas en el proceso de trabajo propietario de
esos archivos.
