Optimiza PNG en el navegador con Oxipng
La optimización de PNG en el navegador puede reducir el tamaño de subida sin enviar primero la
imagen a un servidor. Esta guía usa @jsquash/oxipng, que ejecuta Oxipng mediante
WebAssembly. Oxipng es un optimizador distinto de OptiPNG; la URL histórica de la página conserva
el nombre anterior.
Cómo funciona Oxipng
Oxipng modifica la compresión y la representación del PNG sin cambiar los píxeles decodificados. Desactivamos la optimización de píxeles transparentes, ya que cambiar los valores RGB invisibles violaría la igualdad estricta de píxeles. Los metadatos y la representación binaria del archivo pueden cambiar; conservar los píxeles sin pérdida no implica archivos idénticos byte a byte.
Usamos el códec de un solo hilo del paquete, con una versión fijada, dentro de un Worker dedicado. La interfaz sigue respondiendo y la cancelación termina ese Worker. Esto evita requerir aislamiento entre orígenes o un grupo anidado de Workers.
Implementación
Usa un directorio de proyecto nuevo y vacío. La configuración se probó en Linux con
Node.js 24.15.0 y su versión incluida de npm 11.12.1,
y con Node.js 26.8.1 y npm 12.0.2. Estas son versiones probadas; las versiones posteriores de
Node.js 24.x que siguen recibiendo mantenimiento también son adecuadas. Comprueba
node --version y npm --version antes de guardar los archivos.
Guarda los cuatro archivos siguientes en ese directorio. En package.json:
{
"name": "browser-png-optimizer",
"private": true,
"type": "module",
"scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" },
"dependencies": { "@jsquash/oxipng": "2.3.0" },
"devDependencies": { "vite": "7.3.6" }
}
En index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PNG optimizer</title>
</head>
<body>
<h1>PNG optimizer</h1>
<label for="file">PNG file</label>
<input id="file" type="file" accept="image/png">
<button id="cancel" type="button" disabled>Cancel</button>
<p id="status" role="status">Ready.</p>
<a id="download" hidden>Download PNG</a>
<script type="module" src="/main.js"></script>
</body>
</html>
En main.js, cada tarea tiene su propio Worker y su propia URL de descarga.
Seleccionar un archivo inicia la tarea; no se suben datos de la imagen:
const input = document.getElementById('file')
const cancel = document.getElementById('cancel')
const status = document.getElementById('status')
const download = document.getElementById('download')
let worker
let timer
let job = 0
let downloadUrl
function release() {
worker?.terminate()
worker = undefined
clearTimeout(timer)
input.value = ''
input.disabled = false
cancel.disabled = true
}
function clearDownload() {
if (downloadUrl) URL.revokeObjectURL(downloadUrl)
downloadUrl = undefined
download.hidden = true
download.removeAttribute('href')
}
cancel.addEventListener('click', () => {
job += 1
release()
status.textContent = 'Canceled.'
})
input.addEventListener('change', async () => {
const file = input.files?.[0]
if (!file) return
const current = ++job
release()
clearDownload()
if (file.size === 0 || file.size > 8 * 1024 * 1024) {
status.textContent = 'Choose a PNG no larger than 8 MiB.'
return
}
input.disabled = true
cancel.disabled = false
status.textContent = 'Optimizing PNG.'
const fail = (message) => {
if (current !== job) return
job += 1
release()
status.textContent = message
}
timer = setTimeout(() => fail('Optimization timed out.'), 30_000)
try {
const bytes = await file.arrayBuffer()
if (current !== job) return
worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' })
worker.addEventListener('error', () => fail('PNG optimization failed.'))
worker.addEventListener('messageerror', () => fail('PNG optimization failed.'))
worker.addEventListener('message', ({ data }) => {
if (current !== job) return
if (!data.ok) return fail('Choose a valid, non-animated PNG within the image limits.')
const result = new Blob([data.bytes], { type: 'image/png' })
const output = result.size < file.size ? result : file
downloadUrl = URL.createObjectURL(output)
download.href = downloadUrl
download.download = 'optimized.png'
download.hidden = false
status.textContent = result.size < file.size
? 'Optimized PNG is ready.'
: 'The original PNG is already as small or smaller.'
release()
})
worker.postMessage(bytes, [bytes])
} catch {
fail('PNG optimization failed.')
}
})
window.addEventListener('pagehide', () => {
job += 1
release()
clearDownload()
})
Usa Web Workers para mejorar el rendimiento
En worker.js, comprueba los límites de asignación de memoria antes de llamar al
optimizador de PNG. Las comprobaciones de cabeceras y bloques son medidas de protección, no un
sustituto de la validación del códec. Los PNG animados se rechazan para que el límite de píxeles
corresponda a una sola imagen.
import init, { optimise } from '@jsquash/oxipng/codec/pkg/squoosh_oxipng.js'
import wasmUrl from '@jsquash/oxipng/codec/pkg/squoosh_oxipng_bg.wasm?url'
function validatePng(buffer) {
const data = new Uint8Array(buffer)
const signature = [137, 80, 78, 71, 13, 10, 26, 10]
if (data.length < 33 || data.length > 8 * 1024 * 1024 ||
!signature.every((byte, i) => data[i] === byte)) throw new Error('Invalid PNG.')
const view = new DataView(buffer)
if (view.getUint32(8) !== 13 || view.getUint32(12) !== 0x49484452) {
throw new Error('Missing PNG header.')
}
const width = view.getUint32(16)
const height = view.getUint32(20)
if (width === 0 || height === 0 || width > 4096 || height > 4096 ||
width * height > 4_000_000) throw new Error('Image exceeds pixel limit.')
let offset = 8
while (offset + 12 <= data.length) {
const length = view.getUint32(offset)
const type = view.getUint32(offset + 4)
if (length > data.length - offset - 12 || type === 0x6163544c) {
throw new Error('Invalid or animated PNG.')
}
offset += length + 12
if (type === 0x49454e44) {
if (length !== 0 || offset !== data.length) throw new Error('Invalid PNG ending.')
return
}
}
throw new Error('Truncated PNG.')
}
self.addEventListener('message', async ({ data }) => {
try {
if (!(data instanceof ArrayBuffer)) throw new Error('Invalid input.')
validatePng(data)
const response = await fetch(wasmUrl)
if (!response.ok) throw new Error('WASM unavailable.')
await init(await response.arrayBuffer())
const bytes = optimise(new Uint8Array(data), 2, false, false)
self.postMessage({ ok: true, bytes: bytes.buffer }, [bytes.buffer])
} catch {
self.postMessage({ ok: false })
}
})
La llamada de bajo nivel a optimise
pasa el nivel 2, desactiva el entrelazado y deja intactos los valores RGB transparentes.
El sufijo ?url es una
importación de activos de Vite. Vite genera el archivo WASM
junto con la aplicación. La ruta del códec está fijada a esta versión del paquete; verifícala al
actualizar. Consulta la documentación del paquete jSquash
para conocer la API de alto nivel y las opciones de múltiples hilos.
Compatibilidad con navegadores
Desde el directorio del proyecto, instala las versiones fijadas de los paquetes e inicia la aplicación:
npm install && npm run dev
Conserva el archivo package-lock.json generado. Si la instalación falla, resuelve el error
de dependencias o de red indicado y repite este comando en el mismo directorio; los archivos fuente
guardados permanecen en su lugar. Abre la URL local que muestra Vite en un navegador compatible con
Workers de módulo, WebAssembly, las API File/Blob y las URL de objeto. El flujo de trabajo completo
se probó en Chromium 145 en Linux; otros motores de navegador y dispositivos móviles necesitan sus
propias comprobaciones.
Selecciona un archivo con PNG file. El estado cambia a
Optimized PNG is ready. Haz clic en
Download PNG para guardar optimized.png.
Si el archivo resultante no es más pequeño, verás
The original PNG is already as small or smaller.
En ese caso, la descarga contiene los bytes originales.
Detén el servidor de desarrollo con Ctrl+C. Compila y prueba el mismo flujo de selección y descarga de archivos con los activos de producción:
npm run build && npm run preview
Abre la URL de vista previa que muestra Vite y detén ese servidor con Ctrl+C cuando termines. La vista previa de Vite sirve la compilación local para comprobarla; usa un alojamiento estático para el despliegue.
Al abrir la aplicación se descarga su JS; la primera tarea también descarga el activo WASM. Las imágenes permanecen en el dispositivo, pero descargar esos activos de la aplicación sigue siendo actividad de red.
Consideraciones de rendimiento
El ejemplo acepta como máximo 8 MiB, 4 millones de píxeles y 4096 píxeles por lado. Ejecuta una tarea a la vez y termina el procesamiento después de 30 segundos o al cancelarlo. Estos son límites de la aplicación, no garantías sobre el uso máximo de memoria del navegador. Empieza con límites conservadores en dispositivos móviles.
El nivel de compresión 2 mantiene una carga moderada en el ejemplo. Los niveles superiores pueden tardar más sin lograr ahorros significativos. Si el archivo generado es más grande, se sigue descargando el original.
Cancela y vuelve a intentarlo
Haz clic en Cancel mientras la tarea esté pendiente. Esto termina el Worker y cambia el estado a Canceled. Puedes seleccionar el mismo archivo de nuevo o elegir otro. Al iniciar una tarea se elimina la descarga anterior; una tarea cancelada o fallida no tiene ningún resultado para descargar.
Un archivo vacío o de más de 8 MiB se rechaza inmediatamente. El Worker rechaza las cabeceras inválidas, los PNG animados y las imágenes que superan los límites de dimensiones o píxeles antes de cargar el códec. El códec comprueba los datos restantes del PNG. Después de 30 segundos, una tarea bloqueada se termina y el estado muestra Optimization timed out. Puedes volver a seleccionar un archivo para intentarlo de nuevo.
Si falla un PNG que sabes que es válido, comprueba que el activo .wasm
responda correctamente en el panel de red del navegador. Si falta un activo, no habrá descarga,
igual que cuando ocurre un error del códec. Vuelve a compilar con las versiones fijadas de los
paquetes y prueba la vista previa de nuevo. Las imágenes permanecen en el dispositivo en este
ejemplo; un servidor que reciba una subida posterior debe validar el archivo de todos modos.
Para procesar más formatos en el servidor, explora el servicio de procesamiento de imágenes de Transloadit.
