完善客户端模块与工作区能力
This commit is contained in:
148
src/lib/user-avatar.ts
Normal file
148
src/lib/user-avatar.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user