// Image processing utilities /** The one refusal signal: the browser could not decode the uploaded file. */ export class UndecodableImageError extends Error { constructor( message = 'Image could not be decoded' ) { super( message ); this.name = 'UndecodableImageError'; } } export interface EncodeResult { dataUrl: string; bytes: number; } export interface LoadedImage { width: number; height: number; hasAlpha: boolean; encode: ( width: number, height: number, format: 'jpeg' | 'png', quality: number ) => EncodeResult; } export type ImageLoader = ( imageData: string ) => Promise; export interface CompressOptions { maxWidth: number; maxHeight: number; targetBytes: number; startQuality?: number; minQuality?: number; qualityStep?: number; minDimension?: number; dimensionStep?: number; } /** Byte length of the payload encoded in a base64 data URL. */ export function base64ByteLength( dataUrl: string ): number { const comma = dataUrl.indexOf( ',' ); const b64 = comma >= 0 ? dataUrl.slice( comma + 1 ) : dataUrl; const padding = b64.endsWith( '==' ) ? 2 : b64.endsWith( '=' ) ? 1 : 0; return Math.floor( ( b64.length * 3 ) / 4 ) - padding; } function qualitiesFrom( start: number, min: number, step: number ): number[] { const qualities: number[] = []; for ( let q = start; q > min + 1e-9; q -= step ) { qualities.push( Number( q.toFixed( 2 ) ) ); } qualities.push( min ); return qualities; } /** * Normalize an image to a target byte size without ever refusing for size. * Downscale to fit maxWidth/maxHeight, then (JPEG only) step quality down, then * shrink dimensions toward minDimension. At the floor, return best-effort. * Rejects only with UndecodableImageError (via the loader). */ export async function compressToTarget( imageData: string, opts: CompressOptions, loader: ImageLoader ): Promise { const { maxWidth, maxHeight, targetBytes, startQuality = 0.85, minQuality = 0.6, qualityStep = 0.1, minDimension = 512, dimensionStep = 0.85, } = opts; const img = await loader( imageData ); const format: 'jpeg' | 'png' = img.hasAlpha ? 'png' : 'jpeg'; const qualities = format === 'jpeg' ? qualitiesFrom( startQuality, minQuality, qualityStep ) : [ 1 ]; // Fit within the max box; only ever downscale (never upscale). const capRatio = Math.min( 1, maxWidth / img.width, maxHeight / img.height ); const baseWidth = Math.max( 1, Math.round( img.width * capRatio ) ); const baseHeight = Math.max( 1, Math.round( img.height * capRatio ) ); const baseLongEdge = Math.max( baseWidth, baseHeight ); let best: EncodeResult | null = null; let scale = 1; for ( ;; ) { const width = Math.max( 1, Math.round( baseWidth * scale ) ); const height = Math.max( 1, Math.round( baseHeight * scale ) ); for ( const quality of qualities ) { best = img.encode( width, height, format, quality ); if ( best.bytes <= targetBytes ) { return best.dataUrl; } } if ( Math.max( width, height ) <= minDimension ) { break; // dimension floor reached } scale *= dimensionStep; // Clamp so the long edge lands exactly on the floor, not below it. if ( Math.round( baseLongEdge * scale ) < minDimension ) { scale = minDimension / baseLongEdge; } } // Never refuse for size: return the smallest encoding produced. return best!.dataUrl; } /** True if the decoded image has any non-opaque pixel. JPEG never has alpha. */ function detectAlpha( img: HTMLImageElement, dataUrl: string, width: number, height: number ): boolean { if ( dataUrl.startsWith( 'data:image/jpeg' ) || dataUrl.startsWith( 'data:image/jpg' ) ) { return false; } const sampleW = Math.max( 1, Math.min( width, 64 ) ); const sampleH = Math.max( 1, Math.min( height, 64 ) ); const canvas = document.createElement( 'canvas' ); canvas.width = sampleW; canvas.height = sampleH; const ctx = canvas.getContext( '2d' ); if ( ! ctx ) { return false; } ctx.drawImage( img, 0, 0, sampleW, sampleH ); const { data } = ctx.getImageData( 0, 0, sampleW, sampleH ); for ( let i = 3; i < data.length; i += 4 ) { if ( data[ i ] < 255 ) { return true; } } return false; } /** Real-browser loader: decode via , encode via canvas. */ export const browserImageLoader: ImageLoader = ( imageData ) => new Promise( ( resolve, reject ) => { const img = new Image(); img.onload = () => { const width = img.naturalWidth || img.width; const height = img.naturalHeight || img.height; resolve( { width, height, hasAlpha: detectAlpha( img, imageData, width, height ), encode: ( w, h, format, quality ) => { const canvas = document.createElement( 'canvas' ); canvas.width = w; canvas.height = h; const ctx = canvas.getContext( '2d' ); if ( ! ctx ) { throw new UndecodableImageError( 'Canvas 2D context unavailable' ); } ctx.drawImage( img, 0, 0, w, h ); const mime = format === 'png' ? 'image/png' : 'image/jpeg'; const dataUrl = canvas.toDataURL( mime, quality ); return { dataUrl, bytes: base64ByteLength( dataUrl ) }; }, } ); }; img.onerror = () => reject( new UndecodableImageError() ); img.src = imageData; } ); const AVATAR_OPTS: CompressOptions = { maxWidth: 768, maxHeight: 1024, targetBytes: 1_500_000, startQuality: 0.85, minQuality: 0.6, minDimension: 512, }; const GARMENT_OPTS: CompressOptions = { maxWidth: 1024, maxHeight: 1024, targetBytes: 1_500_000, startQuality: 0.85, minQuality: 0.6, minDimension: 512, }; export function resizeAvatarImage( imageData: string, loader: ImageLoader = browserImageLoader ): Promise { return compressToTarget( imageData, AVATAR_OPTS, loader ); } export function compressGarmentImage( imageData: string, loader: ImageLoader = browserImageLoader ): Promise { return compressToTarget( imageData, GARMENT_OPTS, loader ); } export function fileToBase64(file: File, abortSignal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); // Handle abort signal const abortHandler = () => { reader.abort(); reject(new Error('File reading cancelled')); }; if (abortSignal) { abortSignal.addEventListener('abort', abortHandler); } reader.onload = () => { if (abortSignal) { abortSignal.removeEventListener('abort', abortHandler); } if (typeof reader.result === 'string') { resolve(reader.result); } else { reject(new Error('Failed to read file')); } }; reader.onerror = () => { if (abortSignal) { abortSignal.removeEventListener('abort', abortHandler); } reject(new Error('Failed to read file')); }; reader.onabort = () => { if (abortSignal) { abortSignal.removeEventListener('abort', abortHandler); } reject(new Error('File reading cancelled')); }; reader.readAsDataURL(file); }); } export async function fetchImageAsBase64(url: string): Promise { try { const response = await fetch(url); if (!response.ok) { // Detect specific error types if (response.status === 403 || response.status === 401) { throw new Error('Access denied: CORS error or authentication required'); } if (response.status === 404) { throw new Error('Image not found'); } throw new Error(`Failed to fetch image: ${response.status}`); } const blob = await response.blob(); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { if (typeof reader.result === 'string') { resolve(reader.result); } else { reject(new Error('Failed to read blob')); } }; reader.onerror = () => reject(new Error('Failed to read blob')); reader.readAsDataURL(blob); }); } catch (error) { // Detect CORS and network errors if (error instanceof TypeError && error.message.includes('Failed to fetch')) { throw new Error('CORS error or network failure: Could not load image'); } throw error; } } export function downloadImage(base64Data: string, filename: string): void { const link = document.createElement('a'); link.href = base64Data; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); }