update at 2025-10-08 09:18:20
This commit is contained in:
425
src/xiaohongshu/image.ts
Normal file
425
src/xiaohongshu/image.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user