215 lines
7.8 KiB
TypeScript
215 lines
7.8 KiB
TypeScript
import sharp from 'sharp';
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import { PlatformCoverRequirements, CoverAdaptResult } from './types';
|
||
import {Log} from '../log/main';
|
||
|
||
/**
|
||
* 各平台封面要求
|
||
*/
|
||
const PLATFORM_REQUIREMENTS: Record<string, PlatformCoverRequirements> = {
|
||
douyin: {
|
||
width: 1080,
|
||
height: 1920,
|
||
ratio: '9:16',
|
||
maxSize: 5000, // 5MB
|
||
format: 'jpg'
|
||
},
|
||
kuaishou: {
|
||
width: 1080,
|
||
height: 1440,
|
||
ratio: '3:4',
|
||
maxSize: 3000,
|
||
format: 'jpg'
|
||
},
|
||
shipin: {
|
||
width: 1280,
|
||
height: 720,
|
||
ratio: '16:9',
|
||
maxSize: 2000,
|
||
format: 'jpg'
|
||
},
|
||
xiaohongshu: {
|
||
width: 1080,
|
||
height: 1440,
|
||
ratio: '3:4',
|
||
maxSize: 5000, // 5MB
|
||
format: 'jpg'
|
||
}
|
||
};
|
||
|
||
export class CoverAdapter {
|
||
/**
|
||
* 智能适配封面到平台要求
|
||
*/
|
||
static async adaptCover(params: {
|
||
coverPath: string;
|
||
platform: string;
|
||
outputDir: string;
|
||
}): Promise<CoverAdaptResult> {
|
||
const { coverPath, platform, outputDir } = params;
|
||
const requirements = PLATFORM_REQUIREMENTS[platform];
|
||
|
||
if (!requirements) {
|
||
throw new Error(`不支持的平台: ${platform}`);
|
||
}
|
||
|
||
// 检查输入文件是否存在
|
||
if (!fs.existsSync(coverPath)) {
|
||
throw new Error(`封面文件不存在: ${coverPath}`);
|
||
}
|
||
|
||
const adjustments: string[] = [];
|
||
|
||
try {
|
||
// 1. 读取原始图片信息
|
||
const image = sharp(coverPath);
|
||
const metadata = await image.metadata();
|
||
|
||
if (!metadata.width || !metadata.height) {
|
||
throw new Error('无法读取图片尺寸信息');
|
||
}
|
||
|
||
const originalSize = {
|
||
width: metadata.width,
|
||
height: metadata.height
|
||
};
|
||
|
||
Log.info(`原始封面尺寸: ${originalSize.width}x${originalSize.height}`, { platform });
|
||
|
||
// 2. 计算目标尺寸和裁剪策略
|
||
const targetRatio = requirements.width / requirements.height;
|
||
const currentRatio = originalSize.width / originalSize.height;
|
||
|
||
let processedImage = sharp(coverPath);
|
||
|
||
// 3. 处理宽高比不匹配的情况
|
||
// 视频号和抖音都使用contain(保留完整内容),其他平台使用cover(裁剪)
|
||
const usePadding = platform === 'shipin' || platform === 'douyin'; // 视频号和抖音使用填充而不是裁剪
|
||
|
||
if (Math.abs(currentRatio - targetRatio) > 0.01) {
|
||
adjustments.push(`调整宽高比从${currentRatio.toFixed(2)}到${targetRatio.toFixed(2)}`);
|
||
|
||
if (!usePadding) {
|
||
// 其他平台:裁剪策略
|
||
if (currentRatio > targetRatio) {
|
||
// 原图更宽,需要裁剪左右
|
||
const newWidth = Math.round(originalSize.height * targetRatio);
|
||
const left = Math.round((originalSize.width - newWidth) / 2);
|
||
|
||
processedImage = processedImage.extract({
|
||
left: left,
|
||
top: 0,
|
||
width: newWidth,
|
||
height: originalSize.height
|
||
});
|
||
|
||
adjustments.push(`裁剪宽度: ${originalSize.width} -> ${newWidth} (居中裁剪)`);
|
||
} else {
|
||
// 原图更高,需要裁剪上下
|
||
const newHeight = Math.round(originalSize.width / targetRatio);
|
||
const top = Math.round((originalSize.height - newHeight) / 2);
|
||
|
||
processedImage = processedImage.extract({
|
||
left: 0,
|
||
top: top,
|
||
width: originalSize.width,
|
||
height: newHeight
|
||
});
|
||
|
||
adjustments.push(`裁剪高度: ${originalSize.height} -> ${newHeight} (居中裁剪)`);
|
||
}
|
||
} else {
|
||
// 视频号和抖音:使用填充(添加黑边保留完整内容)
|
||
adjustments.push(`使用等比例缩放+黑边填充(保留完整图片)`);
|
||
}
|
||
}
|
||
|
||
// 4. 调整到目标分辨率
|
||
processedImage = processedImage.resize(requirements.width, requirements.height, {
|
||
fit: usePadding ? 'contain' : 'cover', // 抖音和视频号使用contain保留完整内容,其他使用cover裁剪
|
||
position: 'center',
|
||
background: { r: 0, g: 0, b: 0, alpha: 1 } // 黑色背景
|
||
});
|
||
|
||
if (usePadding) {
|
||
adjustments.push(`等比例缩放至: ${requirements.width}x${requirements.height} (添加黑边)`);
|
||
} else {
|
||
adjustments.push(`缩放至: ${requirements.width}x${requirements.height}`);
|
||
}
|
||
|
||
// 5. 转换格式并调整质量
|
||
let pipeline: sharp.Sharp;
|
||
if (requirements.format === 'jpg') {
|
||
pipeline = processedImage.jpeg({ quality: 90 });
|
||
adjustments.push('转换为JPEG格式(质量90%)');
|
||
} else if (requirements.format === 'png') {
|
||
pipeline = processedImage.png({ compressionLevel: 9 });
|
||
adjustments.push('转换为PNG格式');
|
||
} else {
|
||
pipeline = processedImage.webp({ quality: 90 });
|
||
adjustments.push('转换为WebP格式(质量90%)');
|
||
}
|
||
|
||
// 6. 创建输出目录(如果不存在)
|
||
if (!fs.existsSync(outputDir)) {
|
||
fs.mkdirSync(outputDir, { recursive: true });
|
||
}
|
||
|
||
// 7. 保存处理后的图片
|
||
const timestamp = Date.now();
|
||
const adaptedPath = path.join(outputDir, `cover_${platform}_${timestamp}.${requirements.format}`);
|
||
await pipeline.toFile(adaptedPath);
|
||
|
||
// 8. 检查文件大小,如果超过限制则降低质量
|
||
let fileSize = fs.statSync(adaptedPath).size / 1024; // KB
|
||
|
||
if (fileSize > requirements.maxSize) {
|
||
adjustments.push(`文件过大(${fileSize.toFixed(0)}KB),压缩至${requirements.maxSize}KB以内`);
|
||
|
||
let quality = 80;
|
||
while (fileSize > requirements.maxSize && quality > 50) {
|
||
quality -= 10;
|
||
|
||
await sharp(coverPath)
|
||
.resize(requirements.width, requirements.height, {
|
||
fit: usePadding ? 'contain' : 'cover',
|
||
position: 'center',
|
||
background: { r: 0, g: 0, b: 0, alpha: 1 }
|
||
})
|
||
.jpeg({ quality })
|
||
.toFile(adaptedPath);
|
||
|
||
fileSize = fs.statSync(adaptedPath).size / 1024;
|
||
}
|
||
|
||
adjustments.push(`最终质量: ${quality}%,文件大小: ${fileSize.toFixed(0)}KB`);
|
||
}
|
||
|
||
Log.info(`封面适配完成: ${adaptedPath}`, { platform, adjustments });
|
||
|
||
return {
|
||
success: true,
|
||
adaptedPath,
|
||
originalSize,
|
||
adaptedSize: {
|
||
width: requirements.width,
|
||
height: requirements.height
|
||
},
|
||
adjustments
|
||
};
|
||
|
||
} catch (error) {
|
||
Log.error(`封面适配失败`, { platform, error });
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取平台预设要求
|
||
*/
|
||
static getPlatformRequirements(platform: string): PlatformCoverRequirements | null {
|
||
return PLATFORM_REQUIREMENTS[platform] || null;
|
||
}
|
||
}
|