import { browserImageCodec } from './browser-image-codec'; export const USER_AVATAR_MAX_DIMENSION = 512; export const USER_AVATAR_MAX_SOURCE_BYTES = 32 * 1024 * 1024; export const USER_AVATAR_SUPPORTED_MIME_TYPES = [ 'image/png', 'image/jpeg', 'image/webp', ] as const; export type UserAvatarMimeType = typeof USER_AVATAR_SUPPORTED_MIME_TYPES[number]; export type PreparedUserAvatar = { fileName: string; mimeType: UserAvatarMimeType; dataBase64: string; previewUrl: string; width: number; height: number; bytes: number; }; function isSupportedMimeType(value: string): value is UserAvatarMimeType { return (USER_AVATAR_SUPPORTED_MIME_TYPES as readonly string[]).includes(value); } function fileExtension(mimeType: UserAvatarMimeType): string { if (mimeType === 'image/jpeg') return 'jpg'; if (mimeType === 'image/png') return 'png'; return 'webp'; } function bytesToBase64(bytes: Uint8Array): string { let result = ''; const chunkSize = 0x8000; for (let offset = 0; offset < bytes.length; offset += chunkSize) { result += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); } return btoa(result); } async function blobToDataUrl(blob: Blob, mimeType: UserAvatarMimeType): Promise<{ dataBase64: string; previewUrl: string; }> { const dataBase64 = bytesToBase64(new Uint8Array(await blob.arrayBuffer())); return { dataBase64, previewUrl: `data:${mimeType};base64,${dataBase64}`, }; } function encodeCanvas( canvas: HTMLCanvasElement, mimeType: UserAvatarMimeType, quality?: number, ): Promise { return new Promise((resolve, reject) => { try { canvas.toBlob(resolve, mimeType, quality); } catch (error) { reject(error); } }); } async function encodeAvatarCanvas(canvas: HTMLCanvasElement): Promise<{ blob: Blob; mimeType: UserAvatarMimeType; }> { const webp = await encodeCanvas(canvas, 'image/webp', 0.9); if (webp?.type.trim().toLowerCase() === 'image/webp') { return { blob: webp, mimeType: 'image/webp', }; } const png = await encodeCanvas(canvas, 'image/png'); if (png) { return { blob: png, mimeType: 'image/png', }; } throw new Error('头像图片编码失败'); } export async function prepareUserAvatar(file: File): Promise { const mimeType = file.type.trim().toLowerCase(); if (!isSupportedMimeType(mimeType)) { throw new Error('头像仅支持 PNG、JPEG 或 WebP 图片'); } if (file.size > USER_AVATAR_MAX_SOURCE_BYTES) { throw new Error('头像图片不能超过 32 MB'); } const frameCount = await browserImageCodec.inspectFrameCount(file, mimeType); if (frameCount !== 1) { throw new Error(frameCount && frameCount > 1 ? '头像不支持动态图片' : '无法确认头像为静态图片'); } const decoded = await browserImageCodec.decode(file, { imageOrientation: 'from-image', }); const canvas = document.createElement('canvas'); const side = Math.min(decoded.width, decoded.height); const targetSize = Math.max(1, Math.min(USER_AVATAR_MAX_DIMENSION, Math.floor(side))); const sourceX = Math.max(0, (decoded.width - side) / 2); const sourceY = Math.max(0, (decoded.height - side) / 2); canvas.width = targetSize; canvas.height = targetSize; try { const context = canvas.getContext('2d'); if (!context) throw new Error('头像图片处理失败'); context.imageSmoothingEnabled = true; context.imageSmoothingQuality = 'high'; context.clearRect(0, 0, targetSize, targetSize); context.drawImage( decoded.source, sourceX, sourceY, side, side, 0, 0, targetSize, targetSize, ); const encoded = await encodeAvatarCanvas(canvas); const encodedData = await blobToDataUrl(encoded.blob, encoded.mimeType); return { fileName: `avatar.${fileExtension(encoded.mimeType)}`, mimeType: encoded.mimeType, ...encodedData, width: targetSize, height: targetSize, bytes: encoded.blob.size, }; } finally { decoded.dispose(); canvas.width = 1; canvas.height = 1; } }