Files
note2any/src/xiaohongshu/image.ts
2025-10-08 09:18:20 +08:00

425 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 文件image.ts
* 功能:小红书图片处理工具集合。
*
* 提供:
* - 图片格式统一目标PNG
* - EXIF 方向纠正(避免旋转错误)
* - 尺寸/压缩策略(可扩展为自适应裁剪)
* - Base64 / Blob 转换辅助
*
* 说明:当前为前端侧工具,未接入后端压缩/去重;
* 若后续需要高质量/批量处理,可接入本地原生库或后端服务。
*/
import {
XiaohongshuImageProcessor,
ProcessedImage,
XIAOHONGSHU_CONSTANTS
} from './types';
/**
* XiaohongshuImageHandler
*
* 说明(中文注释):
* 小红书图片处理器,负责将各种格式的图片转换为小红书平台支持的格式。
*
* 主要功能:
* - 统一转换为PNG格式根据用户需求
* - 处理图片尺寸优化
* - EXIF方向信息处理复用现有逻辑
* - 图片质量压缩
* - 批量图片处理
*
* 设计原则:
* - 复用项目现有的图片处理能力
* - 保持图片质量的前提下优化文件大小
* - 支持所有常见图片格式
* - 提供灵活的配置选项
*/
export class XiaohongshuImageHandler implements XiaohongshuImageProcessor {
/**
* 转换图片为PNG格式
* 使用Canvas API进行格式转换
*/
async convertToPNG(imageBlob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('无法获取Canvas上下文'));
return;
}
img.onload = () => {
try {
// 设置canvas尺寸
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
// 清除canvas并绘制图片
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
// 转换为PNG格式的Blob
canvas.toBlob((pngBlob) => {
if (pngBlob) {
resolve(pngBlob);
} else {
reject(new Error('PNG转换失败'));
}
}, 'image/png', 1.0);
} catch (error) {
reject(new Error(`图片转换失败: ${error.message}`));
}
};
img.onerror = () => {
reject(new Error('图片加载失败'));
};
// 加载图片
const imageUrl = URL.createObjectURL(imageBlob);
const originalOnLoad = img.onload;
img.onload = (event) => {
URL.revokeObjectURL(imageUrl);
if (originalOnLoad) {
originalOnLoad.call(img, event);
}
};
img.src = imageUrl;
});
}
/**
* 优化图片质量和尺寸
* 根据小红书平台要求调整图片
*/
async optimizeImage(
imageBlob: Blob,
quality: number = 85,
maxWidth?: number,
maxHeight?: number
): Promise<Blob> {
const { RECOMMENDED_SIZE } = XIAOHONGSHU_CONSTANTS.IMAGE_LIMITS;
const targetWidth = maxWidth || RECOMMENDED_SIZE.width;
const targetHeight = maxHeight || RECOMMENDED_SIZE.height;
return new Promise((resolve, reject) => {
const img = new Image();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('无法获取Canvas上下文'));
return;
}
img.onload = () => {
try {
let { naturalWidth: width, naturalHeight: height } = img;
// 计算缩放比例
const scaleX = targetWidth / width;
const scaleY = targetHeight / height;
const scale = Math.min(scaleX, scaleY, 1); // 不放大图片
// 计算新尺寸
const newWidth = Math.floor(width * scale);
const newHeight = Math.floor(height * scale);
// 设置canvas尺寸
canvas.width = newWidth;
canvas.height = newHeight;
// 使用高质量缩放
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// 绘制缩放后的图片
ctx.drawImage(img, 0, 0, newWidth, newHeight);
// 转换为指定质量的PNG
canvas.toBlob((optimizedBlob) => {
if (optimizedBlob) {
resolve(optimizedBlob);
} else {
reject(new Error('图片优化失败'));
}
}, 'image/png', quality / 100);
} catch (error) {
reject(new Error(`图片优化失败: ${error.message}`));
}
};
img.onerror = () => {
reject(new Error('图片加载失败'));
};
const imageUrl = URL.createObjectURL(imageBlob);
img.src = imageUrl;
});
}
/**
* 处理EXIF方向信息
* 复用现有的EXIF处理逻辑
*/
private async handleEXIFOrientation(imageBlob: Blob): Promise<Blob> {
// 检查是否为JPEG格式
if (!imageBlob.type.includes('jpeg') && !imageBlob.type.includes('jpg')) {
return imageBlob;
}
try {
// 读取EXIF信息
const arrayBuffer = await imageBlob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
// 查找EXIF orientation标记
let orientation = 1;
// 简单的EXIF解析查找orientation标记
if (uint8Array[0] === 0xFF && uint8Array[1] === 0xD8) { // JPEG标记
let offset = 2;
while (offset < uint8Array.length) {
if (uint8Array[offset] === 0xFF && uint8Array[offset + 1] === 0xE1) {
// 找到EXIF段
const exifLength = (uint8Array[offset + 2] << 8) | uint8Array[offset + 3];
const exifData = uint8Array.slice(offset + 4, offset + 4 + exifLength);
// 查找orientation标记0x0112
for (let i = 0; i < exifData.length - 8; i++) {
if (exifData[i] === 0x01 && exifData[i + 1] === 0x12) {
orientation = exifData[i + 8] || 1;
break;
}
}
break;
}
offset += 2;
if (uint8Array[offset - 2] === 0xFF) {
const segmentLength = (uint8Array[offset] << 8) | uint8Array[offset + 1];
offset += segmentLength;
}
}
}
// 如果需要旋转
if (orientation > 1) {
return await this.rotateImage(imageBlob, orientation);
}
return imageBlob;
} catch (error) {
console.warn('EXIF处理失败使用原图:', error);
return imageBlob;
}
}
/**
* 根据EXIF方向信息旋转图片
*/
private async rotateImage(imageBlob: Blob, orientation: number): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('无法获取Canvas上下文'));
return;
}
img.onload = () => {
const { naturalWidth: width, naturalHeight: height } = img;
// 根据orientation设置变换
switch (orientation) {
case 3: // 180度
canvas.width = width;
canvas.height = height;
ctx.rotate(Math.PI);
ctx.translate(-width, -height);
break;
case 6: // 顺时针90度
canvas.width = height;
canvas.height = width;
ctx.rotate(Math.PI / 2);
ctx.translate(0, -height);
break;
case 8: // 逆时针90度
canvas.width = height;
canvas.height = width;
ctx.rotate(-Math.PI / 2);
ctx.translate(-width, 0);
break;
default:
canvas.width = width;
canvas.height = height;
break;
}
ctx.drawImage(img, 0, 0);
canvas.toBlob((rotatedBlob) => {
if (rotatedBlob) {
resolve(rotatedBlob);
} else {
reject(new Error('图片旋转失败'));
}
}, 'image/png', 1.0);
};
img.onerror = () => {
reject(new Error('图片加载失败'));
};
const imageUrl = URL.createObjectURL(imageBlob);
img.src = imageUrl;
});
}
/**
* 批量处理图片
* 对多张图片进行统一处理
*/
async processImages(images: { name: string; blob: Blob }[]): Promise<ProcessedImage[]> {
const results: ProcessedImage[] = [];
for (const { name, blob } of images) {
try {
console.log(`[XiaohongshuImageHandler] 处理图片: ${name}`);
// 检查文件大小
if (blob.size > XIAOHONGSHU_CONSTANTS.IMAGE_LIMITS.MAX_SIZE) {
console.warn(`图片 ${name} 过大 (${Math.round(blob.size / 1024)}KB),将进行压缩`);
}
// 处理EXIF方向
let processedBlob = await this.handleEXIFOrientation(blob);
// 优化图片转换为PNG并调整尺寸
processedBlob = await this.optimizeImage(processedBlob, 85);
// 转换为PNG格式
const pngBlob = await this.convertToPNG(processedBlob);
// 获取处理后的图片尺寸
const dimensions = await this.getImageDimensions(pngBlob);
results.push({
originalName: name,
blob: pngBlob,
dimensions,
size: pngBlob.size
});
console.log(`[XiaohongshuImageHandler] 图片 ${name} 处理完成: ${dimensions.width}x${dimensions.height}, ${Math.round(pngBlob.size / 1024)}KB`);
} catch (error) {
console.error(`处理图片 ${name} 失败:`, error);
// 继续处理其他图片,不抛出异常
}
}
return results;
}
/**
* 获取图片尺寸信息
*/
private async getImageDimensions(imageBlob: Blob): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
resolve({
width: img.naturalWidth,
height: img.naturalHeight
});
URL.revokeObjectURL(img.src);
};
img.onerror = () => {
reject(new Error('无法获取图片尺寸'));
};
img.src = URL.createObjectURL(imageBlob);
});
}
/**
* 验证图片格式是否支持
*/
static isSupportedFormat(filename: string): boolean {
const ext = filename.toLowerCase().split('.').pop() || '';
return XIAOHONGSHU_CONSTANTS.IMAGE_LIMITS.SUPPORTED_FORMATS.includes(ext as any);
}
/**
* 创建图片预览URL
* 用于界面预览
*/
static createPreviewUrl(imageBlob: Blob): string {
return URL.createObjectURL(imageBlob);
}
/**
* 清理预览URL
*/
static revokePreviewUrl(url: string): void {
URL.revokeObjectURL(url);
}
/**
* 获取图片处理统计信息
*/
static getProcessingStats(original: { name: string; blob: Blob }[], processed: ProcessedImage[]): {
totalOriginalSize: number;
totalProcessedSize: number;
compressionRatio: number;
processedCount: number;
failedCount: number;
} {
const totalOriginalSize = original.reduce((sum, img) => sum + img.blob.size, 0);
const totalProcessedSize = processed.reduce((sum, img) => sum + img.size, 0);
return {
totalOriginalSize,
totalProcessedSize,
compressionRatio: totalOriginalSize > 0 ? totalProcessedSize / totalOriginalSize : 0,
processedCount: processed.length,
failedCount: original.length - processed.length
};
}
}
/**
* 小红书图片处理器管理类
* 提供单例模式的图片处理器
*/
export class XiaohongshuImageManager {
private static instance: XiaohongshuImageHandler | null = null;
/**
* 获取图片处理器实例
*/
static getInstance(): XiaohongshuImageHandler {
if (!this.instance) {
this.instance = new XiaohongshuImageHandler();
}
return this.instance;
}
/**
* 销毁实例
*/
static destroyInstance(): void {
this.instance = null;
}
}