Files
makelore/src/lib/agent-avatar-upload.ts

134 lines
4.0 KiB
TypeScript

import { browserImageCodec } from './browser-image-codec';
export const AGENT_AVATAR_MAX_DIMENSION = 256;
export const AGENT_AVATAR_MAX_SOURCE_BYTES = 32 * 1024 * 1024;
export const AGENT_AVATAR_WEBP_QUALITY = 0.8;
export const AGENT_AVATAR_SUPPORTED_MIME_TYPES = [
'image/png',
'image/jpeg',
'image/webp',
] as const;
export type AgentAvatarMimeType = typeof AGENT_AVATAR_SUPPORTED_MIME_TYPES[number];
export type PreparedAgentAvatar = {
fileName: string;
mimeType: AgentAvatarMimeType;
previewUrl: string;
width: number;
height: number;
bytes: number;
};
function isSupportedMimeType(value: string): value is AgentAvatarMimeType {
return (AGENT_AVATAR_SUPPORTED_MIME_TYPES as readonly string[]).includes(value);
}
function fileExtension(mimeType: AgentAvatarMimeType): 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: AgentAvatarMimeType): Promise<string> {
const dataBase64 = bytesToBase64(new Uint8Array(await blob.arrayBuffer()));
return `data:${mimeType};base64,${dataBase64}`;
}
function encodeCanvas(
canvas: HTMLCanvasElement,
mimeType: AgentAvatarMimeType,
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: AgentAvatarMimeType;
}> {
const webp = await encodeCanvas(canvas, 'image/webp', AGENT_AVATAR_WEBP_QUALITY);
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 prepareAgentAvatar(file: File): Promise<PreparedAgentAvatar> {
const mimeType = file.type.trim().toLowerCase();
if (!isSupportedMimeType(mimeType)) {
throw new Error('智能体头像仅支持 PNG、JPEG 或 WebP 图片');
}
if (file.size > AGENT_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(AGENT_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);
return {
fileName: `avatar.${fileExtension(encoded.mimeType)}`,
mimeType: encoded.mimeType,
previewUrl: await blobToDataUrl(encoded.blob, encoded.mimeType),
width: targetSize,
height: targetSize,
bytes: encoded.blob.size,
};
} finally {
decoded.dispose();
canvas.width = 1;
canvas.height = 1;
}
}