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