Source: image-processor.js

// IMAGE PROCESSOR — Canvas resize, compress, and format pipeline
// =============================================================================

/**
 * ImageProcessor provides client-side image processing using the Canvas API.
 * Handles resize, format selection (WebP with JPEG fallback), quality management,
 * and byte-budget enforcement via iterative quality reduction.
 *
 * Used by ImageCache for Numista CDN images and by the upload flow for user photos.
 *
 * @class
 */
class ImageProcessor {
  /**
   * @param {Object} [options]
   * @param {number} [options.maxDim=600]      - Max width/height in px
   * @param {number} [options.quality=0.75]     - Initial compression quality (0-1)
   * @param {number} [options.maxBytes=512000]  - Max output size in bytes
   * @param {number} [options.qualityStep=0.05] - Quality reduction step when over budget
   * @param {number} [options.minQuality=0.30]  - Floor quality to avoid excessive degradation
   */
  constructor(options = {}) {
    this.maxDim = options.maxDim ?? IMAGE_MAX_DIM ?? 600;
    this.quality = options.quality ?? IMAGE_QUALITY ?? 0.75;
    this.maxBytes = options.maxBytes ?? IMAGE_MAX_BYTES ?? 512000;
    this.qualityStep = options.qualityStep ?? 0.05;
    this.minQuality = options.minQuality ?? 0.30;

    /** @type {boolean|null} Cached WebP support detection result */
    this._webpSupported = null;
  }

  // ---------------------------------------------------------------------------
  // Public API
  // ---------------------------------------------------------------------------

  /**
   * Process a File or Blob into a compressed, resized image.
   * @param {File|Blob} file - Input image file
   * @param {Object} [opts] - Override options for this call
   * @param {number} [opts.maxDim]   - Override max dimension
   * @param {number} [opts.quality]  - Override quality
   * @param {number} [opts.maxBytes] - Override max bytes
   * @returns {Promise<{blob: Blob, width: number, height: number, originalSize: number, compressedSize: number, format: string}|null>}
   */
  async processFile(file, opts = {}) {
    if (!file || !(file instanceof Blob)) return null;

    const originalSize = file.size;

    try {
      const bitmap = await createImageBitmap(file);
      const result = await this._processSource(bitmap, { ...opts, originalSize });
      bitmap.close();
      return result;
    } catch (err) {
      console.warn('ImageProcessor: processFile failed', err);
      return null;
    }
  }

  /**
   * Process an image from a URL into a compressed, resized blob.
   * @param {string} url - Image URL to fetch and process
   * @param {Object} [opts] - Override options
   * @returns {Promise<{blob: Blob, width: number, height: number, originalSize: number, compressedSize: number, format: string}|null>}
   */
  async processFromUrl(url, opts = {}) {
    if (!url || typeof url !== 'string') return null;

    try {
      const resp = await fetch(url, { mode: 'cors' });
      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
      const blob = await resp.blob();
      return this.processFile(blob, opts);
    } catch (err) {
      console.warn('ImageProcessor: processFromUrl failed', err);
      return null;
    }
  }

  /**
   * Create an object URL preview from a Blob. Caller must revoke.
   * @param {Blob} blob
   * @returns {string|null} Object URL or null
   */
  createPreview(blob) {
    if (!blob || !(blob instanceof Blob)) return null;
    return URL.createObjectURL(blob);
  }

  /**
   * Estimate storage bytes for a blob (actual blob.size).
   * @param {Blob} blob
   * @returns {number}
   */
  estimateStorage(blob) {
    return blob?.size || 0;
  }

  /**
   * Detect whether the browser supports WebP encoding via Canvas.
   * Result is cached after first call.
   * @returns {Promise<boolean>}
   */
  async supportsWebP() {
    if (this._webpSupported !== null) return this._webpSupported;

    try {
      const c = document.createElement('canvas');
      c.width = 1;
      c.height = 1;
      const blob = await new Promise((resolve) => {
        c.toBlob((b) => resolve(b), 'image/webp', 0.5);
      });
      this._webpSupported = !!(blob && blob.type === 'image/webp');
    } catch {
      this._webpSupported = false;
    }

    return this._webpSupported;
  }

  // ---------------------------------------------------------------------------
  // Private pipeline
  // ---------------------------------------------------------------------------

  /**
   * Core processing pipeline: resize → compress → enforce byte budget.
   * @param {ImageBitmap|HTMLImageElement} source
   * @param {Object} opts
   * @returns {Promise<{blob: Blob, width: number, height: number, originalSize: number, compressedSize: number, format: string}|null>}
   */
  async _processSource(source, opts = {}) {
    const maxDim = opts.maxDim ?? this.maxDim;
    const maxBytes = opts.maxBytes ?? this.maxBytes;
    let quality = opts.quality ?? this.quality;

    // Calculate scaled dimensions maintaining aspect ratio
    let { width, height } = source;
    const scale = Math.min(maxDim / width, maxDim / height, 1);
    width = Math.round(width * scale);
    height = Math.round(height * scale);

    // Draw to canvas
    const canvas = document.createElement('canvas');
    canvas.width = width;
    canvas.height = height;
    const ctx = canvas.getContext('2d');
    ctx.drawImage(source, 0, 0, width, height);

    // Determine output format
    const useWebP = await this.supportsWebP();
    const format = useWebP ? 'image/webp' : 'image/jpeg';

    // Compress with iterative quality reduction to meet byte budget
    let blob = await this._canvasToBlob(canvas, format, quality);
    if (!blob) return null;

    while (blob.size > maxBytes && quality > this.minQuality) {
      quality = Math.max(quality - this.qualityStep, this.minQuality);
      blob = await this._canvasToBlob(canvas, format, quality);
      if (!blob) return null;
    }

    return {
      blob,
      width,
      height,
      originalSize: opts.originalSize || 0,
      compressedSize: blob.size,
      format,
    };
  }

  /**
   * Convert a canvas to a Blob with the given format and quality.
   * @param {HTMLCanvasElement} canvas
   * @param {string} format - MIME type
   * @param {number} quality - 0-1
   * @returns {Promise<Blob|null>}
   */
  _canvasToBlob(canvas, format, quality) {
    return new Promise((resolve) => {
      try {
        canvas.toBlob(
          (blob) => resolve(blob || null),
          format,
          quality
        );
      } catch {
        resolve(null);
      }
    });
  }
}

// Singleton instance exposed globally
const imageProcessor = new ImageProcessor();
if (typeof window !== 'undefined') {
  window.imageProcessor = imageProcessor;
  window.ImageProcessor = ImageProcessor;
}