229805a070
Add WeChat service account routing with sync acks, connectivity tests, and context isolation; document deploy runbooks; and bundle related MindSpace, voice, Plaza, and server gateway changes for production rollout. Co-authored-by: Cursor <cursoragent@cursor.com>
144 lines
4.5 KiB
TypeScript
144 lines
4.5 KiB
TypeScript
export const CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES = 8 * 1024 * 1024;
|
|
export const CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES = 1.5 * 1024 * 1024;
|
|
export const CHAT_IMAGE_MAX_SIDE = 1920;
|
|
const MAX_PIXELS = 2_500_000;
|
|
const ACCEPTED_IMAGE_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
|
|
|
type CompressOptions = {
|
|
maxInputBytes?: number;
|
|
maxOutputBytes?: number;
|
|
maxDimension?: number;
|
|
};
|
|
|
|
function loadImageFromUrl(src: string): Promise<HTMLImageElement> {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => resolve(img);
|
|
img.onerror = () => reject(new Error('图片读取失败'));
|
|
img.src = src;
|
|
});
|
|
}
|
|
|
|
function computeOutputMime(inputType: string) {
|
|
if (ACCEPTED_IMAGE_MIME.has(inputType)) {
|
|
return 'image/jpeg';
|
|
}
|
|
return 'image/jpeg';
|
|
}
|
|
|
|
function canvasToFile(canvas: HTMLCanvasElement, mime: string, quality: number, fileName: string): Promise<File> {
|
|
return new Promise((resolve, reject) => {
|
|
canvas.toBlob(
|
|
(blob) => {
|
|
if (!blob) {
|
|
reject(new Error('图片压缩失败'));
|
|
return;
|
|
}
|
|
resolve(new File([blob], fileName, { type: mime }));
|
|
},
|
|
mime,
|
|
quality,
|
|
);
|
|
});
|
|
}
|
|
|
|
function fitDimensions(width: number, height: number, maxSide: number) {
|
|
if (width <= maxSide && height <= maxSide && width * height <= MAX_PIXELS) {
|
|
return { width, height };
|
|
}
|
|
|
|
const scale = Math.min(maxSide / width, maxSide / height, Math.sqrt(MAX_PIXELS / (width * height)));
|
|
return {
|
|
width: Math.max(1, Math.round(width * scale)),
|
|
height: Math.max(1, Math.round(height * scale)),
|
|
};
|
|
}
|
|
|
|
function drawImageToCanvas(img: HTMLImageElement, width: number, height: number) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) {
|
|
throw new Error('图片处理环境不可用');
|
|
}
|
|
|
|
ctx.fillStyle = '#fff';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
return canvas;
|
|
}
|
|
|
|
async function encodeCanvasWithinLimit(
|
|
canvas: HTMLCanvasElement,
|
|
outputType: string,
|
|
targetName: string,
|
|
maxOutputBytes: number,
|
|
) {
|
|
let quality = 0.86;
|
|
let candidate = await canvasToFile(canvas, outputType, quality, targetName);
|
|
|
|
while (candidate.size > maxOutputBytes && quality > 0.5) {
|
|
quality = Math.max(0.5, quality - 0.08);
|
|
candidate = await canvasToFile(canvas, outputType, quality, targetName);
|
|
}
|
|
|
|
return candidate;
|
|
}
|
|
|
|
export async function compressImageForUpload(
|
|
file: File,
|
|
options: CompressOptions = {},
|
|
): Promise<File> {
|
|
const maxInputBytes = options.maxInputBytes ?? CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES;
|
|
const maxOutputBytes = options.maxOutputBytes ?? CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES;
|
|
const maxDimension = options.maxDimension ?? CHAT_IMAGE_MAX_SIDE;
|
|
|
|
if (!file.type.startsWith('image/')) {
|
|
throw new Error('只支持图片文件');
|
|
}
|
|
|
|
if (file.size > maxInputBytes) {
|
|
throw new Error(`单张图片大小不能超过 ${(maxInputBytes / (1024 * 1024)).toFixed(0)}MB`);
|
|
}
|
|
|
|
if (file.type === 'image/gif') {
|
|
if (file.size > maxOutputBytes) {
|
|
throw new Error(`GIF 当前不支持自动压缩,请使用 JPEG/PNG/WEBP,且单张不超过 ${(maxOutputBytes / (1024 * 1024)).toFixed(0)}MB`);
|
|
}
|
|
return file;
|
|
}
|
|
|
|
const objectUrl = URL.createObjectURL(file);
|
|
try {
|
|
const img = await loadImageFromUrl(objectUrl);
|
|
let { width, height } = fitDimensions(img.width, img.height, maxDimension);
|
|
const needsResize = width !== img.width || height !== img.height;
|
|
|
|
if (!needsResize && file.size <= maxOutputBytes) {
|
|
return file;
|
|
}
|
|
|
|
const outputType = computeOutputMime(file.type);
|
|
const targetName = `${file.name.replace(/\.[^.]+$/, '') || 'image'}.jpg`;
|
|
|
|
let canvas = drawImageToCanvas(img, width, height);
|
|
let candidate = await encodeCanvasWithinLimit(canvas, outputType, targetName, maxOutputBytes);
|
|
|
|
while (candidate.size > maxOutputBytes && Math.max(width, height) > 640) {
|
|
width = Math.max(1, Math.round(width * 0.85));
|
|
height = Math.max(1, Math.round(height * 0.85));
|
|
canvas = drawImageToCanvas(img, width, height);
|
|
candidate = await encodeCanvasWithinLimit(canvas, outputType, targetName, maxOutputBytes);
|
|
}
|
|
|
|
if (candidate.size > maxOutputBytes) {
|
|
throw new Error(`图片压缩后仍过大,请上传更小尺寸图片,目标不超过 ${(maxOutputBytes / (1024 * 1024)).toFixed(0)}MB`);
|
|
}
|
|
|
|
return candidate;
|
|
} finally {
|
|
URL.revokeObjectURL(objectUrl);
|
|
}
|
|
}
|