OneImage
OneImage
·
technicalwebassemblyperformance

Construyendo un Squoosh mejorado: Compresión de imágenes local de alto rendimiento con libimagequant-wasm

Construyendo un Squoosh mejorado: Compresión de imágenes local de alto rendimiento con libimagequant-wasm

Una inmersión profunda en cómo construimos una herramienta de compresión de imágenes de alto rendimiento que respeta la privacidad, combinando la arquitectura de Google Squoosh con libimagequant-wasm, Web Workers y APIs web modernas.

Cuando Google anunció el cierre de Squoosh a principios de 2023, la comunidad de desarrollo web perdió una herramienta valiosa para compresión de imágenes del lado del cliente. En OneImage, vimos una oportunidad no solo de preservar esta funcionalidad, sino de mejorarla. Este artículo detalla el enfoque técnico que adoptamos para construir una versión mejorada y lista para producción de Squoosh que prioriza privacidad, rendimiento y experiencia del desarrollador.

Entendiendo la arquitectura de Google Squoosh

Google Squoosh fue pionero en el concepto de ejecutar codificadores de imágenes completamente en el navegador usando WebAssembly (WASM). La arquitectura original consistía en:

  • Procesamiento del lado del cliente: Toda la compresión ocurre localmente, asegurando privacidad
  • Codecs WebAssembly: Codificadores de velocidad nativa compilados a WASM (MozJPEG, OxiPNG, WebP, AVIF)
  • Web Workers: Descarga de computación pesada para prevenir bloqueo de UI
  • Canvas API: Manipulación de imágenes y generación de vista previa

Aunque revolucionario, Squoosh tenía limitaciones:

  • La compresión PNG dependía solo de OxiPNG, que priorizaba ratio de compresión sobre velocidad
  • Sin capacidades de procesamiento por lotes incorporadas
  • Configuraciones preestablecidas limitadas para casos de uso comunes
  • Lógica de UI y compresión estrechamente acoplada

Nuestra estrategia de mejora

1. Integrando libimagequant-wasm para compresión PNG superior

La piedra angular de nuestra mejora es libimagequant-wasm, un port de WebAssembly de la biblioteca estándar de la industria pngquant. Esta biblioteca usa un sofisticado algoritmo de cuantización de color que produce resultados visualmente superiores comparado con la reducción simple de paleta.

¿Por qué libimagequant?

  • Calidad perceptual: Usa un algoritmo modificado de corte mediano optimizado para percepción humana
  • Paletas adaptativas: Genera paletas óptimas de 2-256 colores basadas en contenido de imagen
  • Manejo de transparencia: Preserva canales alfa mientras comprime
  • Rendimiento: Se ejecuta a velocidad casi nativa gracias a WASM

Detalles de implementación

Así es como integramos libimagequant en nuestro pipeline de compresión:

import LibImageQuant from '@fe-daily/libimagequant-wasm'; 
import * as wasmModule from '@fe-daily/libimagequant-wasm/wasm/libimagequant_wasm.js';

async function compressPNG(imageData: ImageData, level: number): Promise<Uint8Array> {
  const quantizer = new LibImageQuant({ wasmModule });
  
  // Mapear nivel de compresión (0-10) a conteo de colores (256-2)
  const maxColors = Math.max(2, 256 - (25.6 * level));
  
  const quantized = await quantizer.quantizeImageData(imageData, {
    maxColors: Math.floor(maxColors),
    speed: 1,          // Balance entre calidad y velocidad
    quality: {
      min: 0,
      target: 100      // Apuntar a la mayor calidad dentro del límite de color
    }
  });
  
  return new Uint8Array(quantized.pngBytes);
}

Parámetros clave explicados:

  • maxColors: Controla el tamaño de la paleta. Menos colores = archivo más pequeño, pero potencialmente peor calidad
  • speed: Rango 1-10, donde 1 es más lento pero mayor calidad
  • quality.target: Establece el umbral de calidad objetivo (0-100)

Nuestra herramienta Squoosh usa esta implementación para ofrecer ratios de compresión de 60-80% con mínima pérdida perceptual.

2. Arquitectura de un sistema Web Worker robusto

Para evitar que el navegador se congele durante la compresión (especialmente para imágenes grandes u operaciones por lotes), construimos una arquitectura Web Worker dedicada:

// compression-worker.ts
import { EncoderOptions, CompressResult } from './squoosh-types';
import LibImageQuant from '@fe-daily/libimagequant-wasm'; 
import * as wasmModule from '@fe-daily/libimagequant-wasm/wasm/libimagequant_wasm.js';

interface CompressMessage {
  type: 'compress';
  imageData: ImageData;
  options: EncoderOptions;
}

self.onmessage = async (e: MessageEvent<CompressMessage>) => {
  const { type, imageData, options } = e.data;

  if (type === 'compress') {
    try {
      const result = await compress(imageData, options);
      self.postMessage({ success: true, result });
    } catch (error) {
      self.postMessage({
        success: false,
        error: error instanceof Error ? error.message : 'Error desconocido',
      });
    }
  }
};

async function compress(
  imageData: ImageData,
  options: EncoderOptions
): Promise<CompressResult> {
  switch (options.type) {
    case 'png':
      return await compressPNGWithQuantization(imageData, options);
    case 'jpeg':
      return await compressJPEG(imageData, options);
    case 'webp':
      return await compressWebP(imageData, options);
    case 'avif':
      return await compressAVIF(imageData, options);
  }
}

Beneficios del Worker:

  • UI no bloqueante durante compresión
  • Capacidad de cancelar operaciones de larga duración
  • Procesamiento paralelo para operaciones por lotes (múltiples workers)
  • Aislamiento de memoria que previene fugas de memoria del hilo principal

3. Construyendo presets de compresión inteligentes

En lugar de exponer parámetros de codificador crudos, creamos tres presets optimizados para casos de uso comunes:

const PRESET_CONFIGS = {
  highQuality: {
    png: { level: 3 },     // ~200 colores
    jpeg: { quality: 90 },
    webp: { quality: 90 },
    avif: { quality: 85 }
  },
  balanced: {
    png: { level: 5 },     // ~128 colores
    jpeg: { quality: 80 },
    webp: { quality: 80 },
    avif: { quality: 70 }
  },
  minSize: {
    png: { level: 8 },     // ~50 colores
    jpeg: { quality: 60 },
    webp: { quality: 60 },
    avif: { quality: 50 }
  }
};

Estos presets fueron calibrados a través de pruebas extensivas en diversos tipos de imágenes (fotos, ilustraciones, capturas de pantalla, elementos UI) para encontrar compensaciones óptimas de calidad-tamaño.

4. Implementando procesamiento por lotes eficiente

Para usuarios comprimiendo múltiples imágenes, construimos un sistema de cola con seguimiento de progreso:

class BatchCompressor {
  private queue: BatchItem[] = [];
  private activeWorkers: Set<Worker> = new Set();
  private maxConcurrency = navigator.hardwareConcurrency || 4;

  async processBatch(files: File[], options: ProcessorOptions) {
    const batchId = Date.now();
    
    for (const file of files) {
      this.queue.push({
        id: `${batchId}-${file.name}`,
        file,
        status: 'pending',
        options
      });
    }

    await this.processQueue();
  }

  private async processQueue() {
    while (this.queue.some(item => item.status === 'pending')) {
      if (this.activeWorkers.size < this.maxConcurrency) {
        const item = this.queue.find(i => i.status === 'pending');
        if (item) {
          item.status = 'processing';
          await this.processItem(item);
        }
      } else {
        await new Promise(resolve => setTimeout(resolve, 100));
      }
    }
  }
}

Este enfoque:

  • Maximiza utilización de CPU ejecutando workers paralelos
  • Previene caídas del navegador limitando concurrencia
  • Proporciona actualizaciones de progreso en tiempo real a usuarios

Prueba nuestro procesamiento por lotes en acción en OneImage Squoosh.

Optimizaciones de rendimiento

Gestión de memoria

Las imágenes grandes pueden agotar rápidamente la memoria del navegador. Implementamos varias estrategias de mitigación:

async function processLargeImage(file: File): Promise<CompressResult> {
  const MAX_DIMENSION = 4096;
  const img = await loadImage(file);
  
  // Reducir escala si es necesario
  let { width, height } = img;
  if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
    const scale = Math.min(MAX_DIMENSION / width, MAX_DIMENSION / height);
    width = Math.floor(width * scale);
    height = Math.floor(height * scale);
  }
  
  // Usar OffscreenCanvas cuando esté disponible para mejor manejo de memoria
  const canvas = typeof OffscreenCanvas !== 'undefined'
    ? new OffscreenCanvas(width, height)
    : document.createElement('canvas');
  
  canvas.width = width;
  canvas.height = height;
  
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0, width, height);
  
  const imageData = ctx.getImageData(0, 0, width, height);
  
  // Liberar recursos de canvas e imagen inmediatamente
  URL.revokeObjectURL(img.src);
  
  return await compress(imageData, options);
}

Caché de módulos WASM

Los módulos WebAssembly se benefician de caché agresivo:

let cachedWasmModule: typeof wasmModule | null = null;

async function getWasmModule() {
  if (!cachedWasmModule) {
    cachedWasmModule = await import(
      '@fe-daily/libimagequant-wasm/wasm/libimagequant_wasm.js'
    );
  }
  return cachedWasmModule;
}

Esto reduce el tiempo de inicialización de ~500ms a casi instantáneo en compresiones subsecuentes.

Expandiendo el conjunto de herramientas

Mientras Squoosh se enfoca en compresión, construimos una suite completa de herramientas complementarias:

Todas las herramientas comparten los mismos principios arquitectónicos: privacidad primero, impulsadas por WASM y completamente del lado del cliente.

Integración de extensión de navegador

Extendimos la arquitectura de la aplicación web a una extensión de navegador, habilitando:

  • Acceso instantáneo a través de popup de barra de herramientas
  • Integración de menú contextual para compresión con clic derecho
  • Gestión de estado basada en pestañas
  • Almacenamiento local para preferencias de presets

La extensión reutiliza la misma infraestructura Web Worker y WASM, asegurando comportamiento consistente en todas las plataformas.

Despliegue e infraestructura

Edge Computing con Cloudflare

Desplegamos OneImage Squoosh en Cloudflare Pages, aprovechando:

  • CDN global para tiempos de carga inicial <50ms en todo el mundo
  • Compresión HTTP/3 y Brotli para recursos
  • Encabezados de caché inteligentes para módulos WASM
  • Cero arranques en frío (solo recursos estáticos)

Optimización de construcción

Nuestra configuración Next.js incluye:

// next.config.ts
module.exports = {
  webpack: (config, { isServer }) => {
    // Soporte para archivos .wasm
    config.experiments = {
      asyncWebAssembly: true,
      layers: true,
    };
    
    // Optimizar imports de worker
    config.module.rules.push({
      test: /\.worker\.(ts|js)$/,
      use: { loader: 'worker-loader' }
    });
    
    return config;
  },
  
  // División de código agresiva
  experimental: {
    optimizePackageImports: [
      '@jsquash/jpeg',
      '@jsquash/png',
      '@jsquash/webp',
      '@jsquash/avif'
    ]
  }
};

Esto asegura que los codificadores se carguen bajo demanda, manteniendo el bundle inicial bajo 100KB (gzippeado).

Testing y aseguramiento de calidad

Mantenemos cobertura de pruebas integral para lógica de compresión:

// __tests__/advanced-compressor.test.ts
describe('AdvancedImageCompressor', () => {
  it('debería comprimir PNG con libimagequant', async () => {
    const compressor = new AdvancedImageCompressor();
    const mockFile = createMockImageFile('test.png', 1000, 1000);
    
    const result = await compressor.compress(mockFile, {
      encode: { type: 'png', options: { level: 5 } }
    });
    
    expect(result.format).toBe('png');
    expect(result.size).toBeLessThan(mockFile.size);
    expect(result.data).toBeInstanceOf(Uint8Array);
  });
  
  it('debería manejar procesamiento por lotes con concurrencia', async () => {
    const files = Array(10).fill(null).map((_, i) => 
      createMockImageFile(`test-${i}.png`, 500, 500)
    );
    
    const startTime = Date.now();
    await batchCompress(files, { preset: 'balanced' });
    const duration = Date.now() - startTime;
    
    // Debería ser más rápido que procesamiento secuencial
    expect(duration).toBeLessThan(10 * 1000); // <1s por imagen
  });
});

Lecciones aprendidas

  1. WASM está listo para producción: Con carga y caché de módulos adecuados, el rendimiento de WASM rivaliza con aplicaciones nativas
  2. Los Web Workers son esenciales: Para cualquier tarea intensiva en CPU, descargar a workers es no negociable
  3. Presets de usuario > controles crudos: La mayoría de los usuarios prefieren "buenos valores predeterminados" sobre ajuste granular
  4. La memoria importa: Siempre perfila el uso de memoria en imágenes grandes e implementa salvaguardas
  5. La privacidad vende: Enfatizar "sin cargas al servidor" resuena fuertemente con los usuarios

Código abierto y comunidad

Aunque OneImage Squoosh es un producto comercial, contribuimos al ecosistema:

  • Informes de errores y PRs a mantenedores de @jsquash
  • Mejoras de documentación para libimagequant-wasm
  • Compartir benchmarks de rendimiento y mejores prácticas

Conclusión

Construir un Squoosh mejorado requirió más que simplemente integrar libimagequant-wasm. Demandó decisiones arquitectónicas cuidadosas alrededor de Web Workers, gestión de memoria, experiencia de usuario e infraestructura de despliegue. El resultado es una herramienta que respeta la privacidad del usuario mientras entrega rendimiento de compresión de grado profesional.

Prueba OneImage Squoosh hoy, o explora nuestra extensión de navegador para acceso aún más rápido. Para desarrolladores construyendo herramientas similares, esperamos que esta inmersión técnica proporcione un plano útil.

---

Referencias y lectura adicional