Files
makelore/src/lib/user-avatar.ts
inman 22add3f01f
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
完善客户端模块与工作区能力
2026-08-13 19:51:02 +08:00

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;
}
}