update at 2025-10-08 09:18:20

This commit is contained in:
douboer
2025-10-08 09:18:20 +08:00
parent a49e389fe2
commit 584d4151fc
67 changed files with 5363 additions and 892 deletions

425
src/xiaohongshu/image.ts Normal file
View File

@@ -0,0 +1,425 @@
/**
* 文件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;
}
}