149 lines
4.1 KiB
TypeScript
149 lines
4.1 KiB
TypeScript
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<Blob | null> {
|
|
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<PreparedUserAvatar> {
|
|
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;
|
|
}
|
|
}
|