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