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