scaler.ts raw

   1  /**
   2   * Responsive Image Variants - Image Scaler
   3   *
   4   * Client-side image scaling for responsive image variants.
   5   * Generates multiple resolution variants with EXIF stripping.
   6   */
   7  
   8  import { ImageVariant, VARIANT_SIZES, VARIANT_ORDER, ORIGINAL_QUALITY } from './constants'
   9  
  10  /** Result of scaling an image to a variant size */
  11  export type ScaledImage = {
  12    variant: ImageVariant
  13    blob: Blob
  14    width: number
  15    height: number
  16    mimeType: string
  17  }
  18  
  19  /** Options for variant generation */
  20  export type ScaleOptions = {
  21    /** Callback for progress updates (0-100) */
  22    onProgress?: (percent: number) => void
  23  }
  24  
  25  /**
  26   * Load an image file and return an ImageBitmap
  27   * This reads only pixel data, automatically stripping EXIF metadata.
  28   */
  29  async function loadImage(file: File): Promise<ImageBitmap> {
  30    return createImageBitmap(file)
  31  }
  32  
  33  /**
  34   * Determine output MIME type based on input type
  35   * Preserves PNG/WebP for transparency, converts others to JPEG.
  36   */
  37  function getOutputMimeType(inputType: string): string {
  38    if (inputType === 'image/png') return 'image/png'
  39    if (inputType === 'image/webp') return 'image/webp'
  40    if (inputType === 'image/gif') return 'image/png' // Preserve transparency
  41    return 'image/jpeg'
  42  }
  43  
  44  /**
  45   * Scale an image to a target width while preserving aspect ratio
  46   */
  47  function scaleToWidth(
  48    source: ImageBitmap,
  49    targetWidth: number,
  50    mimeType: string,
  51    quality: number
  52  ): Promise<Blob> {
  53    return new Promise((resolve, reject) => {
  54      const aspectRatio = source.height / source.width
  55      const targetHeight = Math.round(targetWidth * aspectRatio)
  56  
  57      const canvas = document.createElement('canvas')
  58      canvas.width = targetWidth
  59      canvas.height = targetHeight
  60  
  61      const ctx = canvas.getContext('2d')
  62      if (!ctx) {
  63        reject(new Error('Failed to get canvas context'))
  64        return
  65      }
  66  
  67      ctx.imageSmoothingEnabled = true
  68      ctx.imageSmoothingQuality = 'high'
  69      ctx.drawImage(source, 0, 0, targetWidth, targetHeight)
  70  
  71      canvas.toBlob(
  72        (blob) => {
  73          if (blob) resolve(blob)
  74          else reject(new Error('Failed to create blob from canvas'))
  75        },
  76        mimeType,
  77        quality
  78      )
  79    })
  80  }
  81  
  82  /**
  83   * Create the original variant (full size, EXIF stripped)
  84   */
  85  function createOriginal(
  86    source: ImageBitmap,
  87    mimeType: string,
  88    quality: number
  89  ): Promise<Blob> {
  90    return new Promise((resolve, reject) => {
  91      const canvas = document.createElement('canvas')
  92      canvas.width = source.width
  93      canvas.height = source.height
  94  
  95      const ctx = canvas.getContext('2d')
  96      if (!ctx) {
  97        reject(new Error('Failed to get canvas context'))
  98        return
  99      }
 100  
 101      ctx.drawImage(source, 0, 0)
 102  
 103      canvas.toBlob(
 104        (blob) => {
 105          if (blob) resolve(blob)
 106          else reject(new Error('Failed to create blob from canvas'))
 107        },
 108        mimeType,
 109        quality
 110      )
 111    })
 112  }
 113  
 114  /**
 115   * Determine which variants to generate based on original image width
 116   * Only generates variants smaller than the original (no upscaling).
 117   */
 118  function getVariantsToGenerate(originalWidth: number): ImageVariant[] {
 119    const variants: ImageVariant[] = ['original']
 120  
 121    for (const [variant, config] of Object.entries(VARIANT_SIZES)) {
 122      if (config.width < originalWidth) {
 123        variants.push(variant as ImageVariant)
 124      }
 125    }
 126  
 127    return variants.sort((a, b) => VARIANT_ORDER.indexOf(a) - VARIANT_ORDER.indexOf(b))
 128  }
 129  
 130  /**
 131   * Generate all applicable image variants for a file
 132   *
 133   * @param file - The image file to scale
 134   * @param options - Optional progress callback
 135   * @returns Array of scaled images, sorted from smallest to largest
 136   */
 137  export async function generateImageVariants(
 138    file: File,
 139    options?: ScaleOptions
 140  ): Promise<ScaledImage[]> {
 141    const { onProgress } = options ?? {}
 142  
 143    onProgress?.(0)
 144  
 145    const bitmap = await loadImage(file)
 146    const mimeType = getOutputMimeType(file.type)
 147  
 148    onProgress?.(10)
 149  
 150    const variantsToGenerate = getVariantsToGenerate(bitmap.width)
 151    const totalVariants = variantsToGenerate.length
 152    const results: ScaledImage[] = []
 153  
 154    for (let i = 0; i < variantsToGenerate.length; i++) {
 155      const variant = variantsToGenerate[i]
 156  
 157      let blob: Blob
 158      let width: number
 159      let height: number
 160  
 161      if (variant === 'original') {
 162        blob = await createOriginal(bitmap, mimeType, ORIGINAL_QUALITY)
 163        width = bitmap.width
 164        height = bitmap.height
 165      } else {
 166        const config = VARIANT_SIZES[variant]
 167        const aspectRatio = bitmap.height / bitmap.width
 168        width = config.width
 169        height = Math.round(config.width * aspectRatio)
 170        blob = await scaleToWidth(bitmap, config.width, mimeType, config.quality)
 171      }
 172  
 173      results.push({ variant, blob, width, height, mimeType })
 174  
 175      const progress = 10 + Math.round(((i + 1) / totalVariants) * 80)
 176      onProgress?.(progress)
 177    }
 178  
 179    onProgress?.(90)
 180  
 181    return results
 182  }
 183  
 184  /**
 185   * Check if a file is a supported image type
 186   */
 187  export function isSupportedImage(file: File): boolean {
 188    const supportedTypes = [
 189      'image/jpeg',
 190      'image/jpg',
 191      'image/png',
 192      'image/webp',
 193      'image/gif'
 194    ]
 195    return supportedTypes.includes(file.type)
 196  }
 197  
 198  /**
 199   * Get file extension from MIME type
 200   */
 201  export function getExtensionFromMimeType(mimeType: string): string {
 202    const extensions: Record<string, string> = {
 203      'image/jpeg': 'jpg',
 204      'image/png': 'png',
 205      'image/webp': 'webp',
 206      'image/gif': 'gif'
 207    }
 208    return extensions[mimeType] ?? 'jpg'
 209  }
 210