image-scaler.ts raw

   1  /**
   2   * Client-side image scaling for responsive image variants
   3   *
   4   * Generates multiple resolution variants of an image with EXIF stripping.
   5   * Follows NIP-XX Responsive Image Variants specification.
   6   */
   7  
   8  export type ImageVariant = 'thumb' | 'mobile-sm' | 'mobile-lg' | 'desktop-sm' | 'desktop-md' | 'desktop-lg' | 'original'
   9  
  10  export type ScaledImage = {
  11    variant: ImageVariant
  12    blob: Blob
  13    width: number
  14    height: number
  15    mimeType: string
  16  }
  17  
  18  export type ScaleOptions = {
  19    /** Callback for progress updates (0-100) */
  20    onProgress?: (percent: number) => void
  21  }
  22  
  23  /** Target widths for each variant per NIP-XX */
  24  const VARIANT_WIDTHS: Record<Exclude<ImageVariant, 'original'>, number> = {
  25    thumb: 128,
  26    'mobile-sm': 512,
  27    'mobile-lg': 1024,
  28    'desktop-sm': 1536,
  29    'desktop-md': 2048,
  30    'desktop-lg': 2560
  31  }
  32  
  33  /** JPEG quality settings per variant */
  34  const VARIANT_QUALITY: Record<ImageVariant, number> = {
  35    thumb: 0.70,
  36    'mobile-sm': 0.75,
  37    'mobile-lg': 0.80,
  38    'desktop-sm': 0.85,
  39    'desktop-md': 0.88,
  40    'desktop-lg': 0.90,
  41    original: 0.92
  42  }
  43  
  44  /** Variants in order from smallest to largest */
  45  const VARIANT_ORDER: ImageVariant[] = ['thumb', 'mobile-sm', 'mobile-lg', 'desktop-sm', 'desktop-md', 'desktop-lg', 'original']
  46  
  47  /**
  48   * Load an image file and return an ImageBitmap (strips EXIF by only reading pixels)
  49   */
  50  async function loadImage(file: File): Promise<ImageBitmap> {
  51    return createImageBitmap(file)
  52  }
  53  
  54  /**
  55   * Get the output MIME type based on input type (preserve format)
  56   */
  57  function getOutputMimeType(inputType: string): string {
  58    // Preserve PNG for transparency, otherwise use JPEG
  59    if (inputType === 'image/png') {
  60      return 'image/png'
  61    }
  62    if (inputType === 'image/webp') {
  63      return 'image/webp'
  64    }
  65    if (inputType === 'image/gif') {
  66      // Convert GIF to PNG to preserve any transparency
  67      return 'image/png'
  68    }
  69    // Default to JPEG for everything else
  70    return 'image/jpeg'
  71  }
  72  
  73  /**
  74   * Scale an image to a target width while preserving aspect ratio
  75   */
  76  function scaleToWidth(
  77    source: ImageBitmap,
  78    targetWidth: number,
  79    mimeType: string,
  80    quality: number
  81  ): Promise<Blob> {
  82    return new Promise((resolve, reject) => {
  83      const aspectRatio = source.height / source.width
  84      const targetHeight = Math.round(targetWidth * aspectRatio)
  85  
  86      const canvas = document.createElement('canvas')
  87      canvas.width = targetWidth
  88      canvas.height = targetHeight
  89  
  90      const ctx = canvas.getContext('2d')
  91      if (!ctx) {
  92        reject(new Error('Failed to get canvas context'))
  93        return
  94      }
  95  
  96      // Use high-quality image smoothing
  97      ctx.imageSmoothingEnabled = true
  98      ctx.imageSmoothingQuality = 'high'
  99  
 100      // Draw the image scaled to the target dimensions
 101      ctx.drawImage(source, 0, 0, targetWidth, targetHeight)
 102  
 103      // Convert to blob
 104      canvas.toBlob(
 105        (blob) => {
 106          if (blob) {
 107            resolve(blob)
 108          } else {
 109            reject(new Error('Failed to create blob from canvas'))
 110          }
 111        },
 112        mimeType,
 113        quality
 114      )
 115    })
 116  }
 117  
 118  /**
 119   * Create the original variant (full size but EXIF stripped)
 120   */
 121  function createOriginal(
 122    source: ImageBitmap,
 123    mimeType: string,
 124    quality: number
 125  ): Promise<Blob> {
 126    return new Promise((resolve, reject) => {
 127      const canvas = document.createElement('canvas')
 128      canvas.width = source.width
 129      canvas.height = source.height
 130  
 131      const ctx = canvas.getContext('2d')
 132      if (!ctx) {
 133        reject(new Error('Failed to get canvas context'))
 134        return
 135      }
 136  
 137      // Draw at original size (this strips EXIF by only copying pixels)
 138      ctx.drawImage(source, 0, 0)
 139  
 140      canvas.toBlob(
 141        (blob) => {
 142          if (blob) {
 143            resolve(blob)
 144          } else {
 145            reject(new Error('Failed to create blob from canvas'))
 146          }
 147        },
 148        mimeType,
 149        quality
 150      )
 151    })
 152  }
 153  
 154  /**
 155   * Determine which variants to generate based on original image width
 156   * Only generates variants smaller than the original
 157   */
 158  function getVariantsToGenerate(originalWidth: number): ImageVariant[] {
 159    const variants: ImageVariant[] = ['original']
 160  
 161    for (const [variant, targetWidth] of Object.entries(VARIANT_WIDTHS)) {
 162      if (targetWidth < originalWidth) {
 163        variants.push(variant as ImageVariant)
 164      }
 165    }
 166  
 167    // Sort by variant order
 168    return variants.sort((a, b) => VARIANT_ORDER.indexOf(a) - VARIANT_ORDER.indexOf(b))
 169  }
 170  
 171  /**
 172   * Generate all applicable image variants for a file
 173   *
 174   * @param file - The image file to scale
 175   * @param options - Optional callbacks
 176   * @returns Array of scaled images, sorted from smallest to largest
 177   */
 178  export async function generateImageVariants(
 179    file: File,
 180    options?: ScaleOptions
 181  ): Promise<ScaledImage[]> {
 182    const { onProgress } = options ?? {}
 183  
 184    onProgress?.(0)
 185  
 186    // Load the image (this reads only pixel data, stripping EXIF)
 187    const bitmap = await loadImage(file)
 188    const mimeType = getOutputMimeType(file.type)
 189  
 190    onProgress?.(10)
 191  
 192    // Determine which variants to generate
 193    const variantsToGenerate = getVariantsToGenerate(bitmap.width)
 194    const totalVariants = variantsToGenerate.length
 195    const results: ScaledImage[] = []
 196  
 197    for (let i = 0; i < variantsToGenerate.length; i++) {
 198      const variant = variantsToGenerate[i]
 199      const quality = VARIANT_QUALITY[variant]
 200  
 201      let blob: Blob
 202      let width: number
 203      let height: number
 204  
 205      if (variant === 'original') {
 206        blob = await createOriginal(bitmap, mimeType, quality)
 207        width = bitmap.width
 208        height = bitmap.height
 209      } else {
 210        const targetWidth = VARIANT_WIDTHS[variant]
 211        const aspectRatio = bitmap.height / bitmap.width
 212        width = targetWidth
 213        height = Math.round(targetWidth * aspectRatio)
 214        blob = await scaleToWidth(bitmap, targetWidth, mimeType, quality)
 215      }
 216  
 217      results.push({
 218        variant,
 219        blob,
 220        width,
 221        height,
 222        mimeType
 223      })
 224  
 225      // Update progress (10-90% for scaling, leaving room for upload)
 226      const progress = 10 + Math.round(((i + 1) / totalVariants) * 80)
 227      onProgress?.(progress)
 228    }
 229  
 230    onProgress?.(90)
 231  
 232    return results
 233  }
 234  
 235  /**
 236   * Check if a file is a supported image type
 237   */
 238  export function isSupportedImage(file: File): boolean {
 239    const supportedTypes = [
 240      'image/jpeg',
 241      'image/jpg',
 242      'image/png',
 243      'image/webp',
 244      'image/gif'
 245    ]
 246    return supportedTypes.includes(file.type)
 247  }
 248  
 249  /**
 250   * Get file extension from MIME type
 251   */
 252  export function getExtensionFromMimeType(mimeType: string): string {
 253    const extensions: Record<string, string> = {
 254      'image/jpeg': 'jpg',
 255      'image/png': 'png',
 256      'image/webp': 'webp',
 257      'image/gif': 'gif'
 258    }
 259    return extensions[mimeType] ?? 'jpg'
 260  }
 261  
 262  export { VARIANT_WIDTHS, VARIANT_QUALITY, VARIANT_ORDER }
 263