|
|
const sharp = require('sharp') |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ImageProcessor { |
|
|
constructor() { |
|
|
|
|
|
this.LONG_IMAGE_RATIO = 2.5 |
|
|
|
|
|
this.OVERLAP_MIN = 50 |
|
|
this.OVERLAP_MAX = 100 |
|
|
|
|
|
this.DEFAULT_OVERLAP = 75 |
|
|
|
|
|
|
|
|
this.OPTIMAL_SEGMENT_HEIGHT = 2500 |
|
|
this.MAX_SEGMENT_HEIGHT = 3000 |
|
|
this.MIN_SEGMENT_HEIGHT = 2000 |
|
|
this.MAX_SEGMENTS = 4 |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async detectLongImage(imageBuffer) { |
|
|
try { |
|
|
const metadata = await sharp(imageBuffer).metadata() |
|
|
const { width, height, format } = metadata |
|
|
|
|
|
if (!width || !height) { |
|
|
throw new Error('无法获取图片尺寸信息') |
|
|
} |
|
|
|
|
|
const isLongImage = height > width * this.LONG_IMAGE_RATIO |
|
|
const threshold = width * this.LONG_IMAGE_RATIO |
|
|
|
|
|
console.log(`[长图检测] 图片尺寸: ${width}x${height} (${format}), 阈值: ${threshold}, 判断: ${isLongImage ? '是长图' : '非长图'}`) |
|
|
|
|
|
return { |
|
|
isLongImage, |
|
|
width, |
|
|
height, |
|
|
format, |
|
|
ratio: height / width, |
|
|
threshold |
|
|
} |
|
|
} catch (error) { |
|
|
throw new Error(`图片尺寸检测失败: ${error.message}`) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
calculateOptimalSegmentHeight(width, height, overlap = this.DEFAULT_OVERLAP) { |
|
|
|
|
|
if (height <= this.MAX_SEGMENT_HEIGHT) { |
|
|
return { |
|
|
segmentHeight: height, |
|
|
segmentCount: 1, |
|
|
strategy: 'single_segment' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
let idealSegmentCount = Math.ceil(height / this.OPTIMAL_SEGMENT_HEIGHT) |
|
|
|
|
|
|
|
|
if (idealSegmentCount > this.MAX_SEGMENTS) { |
|
|
idealSegmentCount = this.MAX_SEGMENTS |
|
|
console.log(`[智能切割] 片段数量超限,强制限制为${this.MAX_SEGMENTS}个片段`) |
|
|
} |
|
|
|
|
|
|
|
|
const baseSegmentHeight = Math.floor(height / idealSegmentCount) |
|
|
|
|
|
|
|
|
if (baseSegmentHeight >= this.MIN_SEGMENT_HEIGHT && baseSegmentHeight <= this.MAX_SEGMENT_HEIGHT) { |
|
|
return { |
|
|
segmentHeight: baseSegmentHeight, |
|
|
segmentCount: idealSegmentCount, |
|
|
strategy: 'optimal_division' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (baseSegmentHeight < this.MIN_SEGMENT_HEIGHT) { |
|
|
const adjustedSegmentCount = Math.max(1, Math.floor(height / this.MIN_SEGMENT_HEIGHT)) |
|
|
const adjustedSegmentHeight = Math.floor(height / adjustedSegmentCount) |
|
|
|
|
|
return { |
|
|
segmentHeight: Math.min(adjustedSegmentHeight, this.MAX_SEGMENT_HEIGHT), |
|
|
segmentCount: adjustedSegmentCount, |
|
|
strategy: 'min_height_constraint' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (baseSegmentHeight > this.MAX_SEGMENT_HEIGHT) { |
|
|
const adjustedSegmentCount = Math.ceil(height / this.MAX_SEGMENT_HEIGHT) |
|
|
const adjustedSegmentHeight = Math.floor(height / adjustedSegmentCount) |
|
|
|
|
|
return { |
|
|
segmentHeight: adjustedSegmentHeight, |
|
|
segmentCount: adjustedSegmentCount, |
|
|
strategy: 'max_height_constraint' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
return { |
|
|
segmentHeight: this.MAX_SEGMENT_HEIGHT, |
|
|
segmentCount: Math.ceil(height / this.MAX_SEGMENT_HEIGHT), |
|
|
strategy: 'fallback' |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
calculateCropRegions(width, height, overlap = this.DEFAULT_OVERLAP) { |
|
|
const regions = [] |
|
|
|
|
|
|
|
|
const optimalConfig = this.calculateOptimalSegmentHeight(width, height, overlap) |
|
|
const { segmentHeight: baseSegmentHeight, segmentCount, strategy } = optimalConfig |
|
|
|
|
|
console.log(`[智能切割] 图片尺寸: ${width}x${height}, 策略: ${strategy}, 预计片段数: ${segmentCount}, 基础高度: ${baseSegmentHeight}`) |
|
|
|
|
|
let currentTop = 0 |
|
|
let segmentIndex = 0 |
|
|
|
|
|
while (currentTop < height && segmentIndex < segmentCount * 2) { |
|
|
const remainingHeight = height - currentTop |
|
|
let segmentHeight = Math.min(baseSegmentHeight, remainingHeight) |
|
|
|
|
|
|
|
|
if (segmentIndex > 0) { |
|
|
const overlapTop = Math.min(overlap, currentTop) |
|
|
currentTop = currentTop - overlapTop |
|
|
segmentHeight = Math.min(baseSegmentHeight + overlapTop, height - currentTop) |
|
|
} |
|
|
|
|
|
|
|
|
const isLastSegment = (currentTop + segmentHeight >= height) || (segmentIndex >= segmentCount - 1) |
|
|
if (!isLastSegment) { |
|
|
const overlapBottom = Math.min(overlap, remainingHeight - segmentHeight) |
|
|
segmentHeight = Math.min(segmentHeight + overlapBottom, height - currentTop) |
|
|
} |
|
|
|
|
|
|
|
|
segmentHeight = Math.max(segmentHeight, this.MIN_SEGMENT_HEIGHT) |
|
|
segmentHeight = Math.min(segmentHeight, this.MAX_SEGMENT_HEIGHT) |
|
|
segmentHeight = Math.min(segmentHeight, height - currentTop) |
|
|
|
|
|
regions.push({ |
|
|
top: currentTop, |
|
|
left: 0, |
|
|
width: width, |
|
|
height: segmentHeight, |
|
|
segmentIndex: segmentIndex, |
|
|
isFirst: segmentIndex === 0, |
|
|
isLast: currentTop + segmentHeight >= height, |
|
|
strategy: strategy |
|
|
}) |
|
|
|
|
|
console.log(`[切割片段] 片段${segmentIndex + 1}: top=${currentTop}, height=${segmentHeight}, 范围=[${currentTop}, ${currentTop + segmentHeight}]`) |
|
|
|
|
|
|
|
|
if (segmentIndex === 0) { |
|
|
|
|
|
currentTop += baseSegmentHeight |
|
|
} else { |
|
|
|
|
|
currentTop += Math.max(baseSegmentHeight - overlap, 1) |
|
|
} |
|
|
|
|
|
segmentIndex++ |
|
|
|
|
|
|
|
|
if (currentTop >= height) { |
|
|
break |
|
|
} |
|
|
} |
|
|
|
|
|
console.log(`[切割完成] 实际生成${regions.length}个片段`) |
|
|
return regions |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getOptimalOutputFormat(originalFormat) { |
|
|
|
|
|
const formatMap = { |
|
|
'jpeg': 'jpeg', |
|
|
'jpg': 'jpeg', |
|
|
'png': 'png', |
|
|
'webp': 'webp', |
|
|
'gif': 'png', |
|
|
'bmp': 'png', |
|
|
'tiff': 'png', |
|
|
'svg': 'png' |
|
|
} |
|
|
|
|
|
return formatMap[originalFormat?.toLowerCase()] || 'png' |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
applyOptimalFormat(sharpInstance, format, options = {}) { |
|
|
switch (format) { |
|
|
case 'jpeg': |
|
|
return sharpInstance.jpeg({ |
|
|
quality: options.quality || 85, |
|
|
progressive: true |
|
|
}) |
|
|
case 'png': |
|
|
return sharpInstance.png({ |
|
|
compressionLevel: options.compressionLevel || 6, |
|
|
progressive: true |
|
|
}) |
|
|
case 'webp': |
|
|
return sharpInstance.webp({ |
|
|
quality: options.quality || 80, |
|
|
effort: 4 |
|
|
}) |
|
|
default: |
|
|
return sharpInstance.png({ compressionLevel: 6 }) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async cropLongImage(imageBuffer, overlap = this.DEFAULT_OVERLAP) { |
|
|
try { |
|
|
const detection = await this.detectLongImage(imageBuffer) |
|
|
|
|
|
if (!detection.isLongImage) { |
|
|
|
|
|
return [{ |
|
|
buffer: imageBuffer, |
|
|
metadata: { |
|
|
segmentIndex: 0, |
|
|
totalSegments: 1, |
|
|
isFirst: true, |
|
|
isLast: true, |
|
|
originalWidth: detection.width, |
|
|
originalHeight: detection.height, |
|
|
segmentWidth: detection.width, |
|
|
segmentHeight: detection.height |
|
|
} |
|
|
}] |
|
|
} |
|
|
|
|
|
const { width, height, format } = detection |
|
|
|
|
|
|
|
|
const optimalConfig = this.calculateOptimalSegmentHeight(width, height, overlap) |
|
|
const regions = this.calculateCropRegions(width, height, overlap) |
|
|
const segments = [] |
|
|
|
|
|
|
|
|
const outputFormat = this.getOptimalOutputFormat(format) |
|
|
|
|
|
console.log(`检测到长图 ${width}x${height} (${format}),智能切割策略: ${optimalConfig.strategy}`) |
|
|
console.log(`切割配置: 目标高度=${optimalConfig.segmentHeight}px, 预计片段=${optimalConfig.segmentCount}个, 实际片段=${regions.length}个`) |
|
|
console.log(`输出格式: ${outputFormat}, 重叠像素: ${overlap}px`) |
|
|
|
|
|
for (const region of regions) { |
|
|
const sharpInstance = sharp(imageBuffer) |
|
|
.extract({ |
|
|
left: region.left, |
|
|
top: region.top, |
|
|
width: region.width, |
|
|
height: region.height |
|
|
}) |
|
|
|
|
|
|
|
|
const segmentBuffer = await this.applyOptimalFormat(sharpInstance, outputFormat) |
|
|
.toBuffer() |
|
|
|
|
|
segments.push({ |
|
|
buffer: segmentBuffer, |
|
|
metadata: { |
|
|
segmentIndex: region.segmentIndex, |
|
|
totalSegments: regions.length, |
|
|
isFirst: region.isFirst, |
|
|
isLast: region.isLast, |
|
|
originalWidth: width, |
|
|
originalHeight: height, |
|
|
segmentWidth: region.width, |
|
|
segmentHeight: region.height, |
|
|
originalFormat: format, |
|
|
outputFormat: outputFormat, |
|
|
strategy: region.strategy, |
|
|
cropRegion: { |
|
|
top: region.top, |
|
|
left: region.left, |
|
|
width: region.width, |
|
|
height: region.height |
|
|
} |
|
|
} |
|
|
}) |
|
|
|
|
|
const heightStatus = region.height <= 2500 ? '✓理想' : region.height <= 3000 ? '✓良好' : '⚠超限' |
|
|
console.log(`生成片段 ${region.segmentIndex + 1}/${regions.length}: ${region.width}x${region.height} (${heightStatus}) [${region.top}-${region.top + region.height}]`) |
|
|
} |
|
|
|
|
|
return segments |
|
|
} catch (error) { |
|
|
throw new Error(`长图切割失败: ${error.message}`) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
generateSegmentName(originalName, segmentIndex, totalSegments) { |
|
|
const nameWithoutExt = originalName.replace(/\.[^/.]+$/, '') |
|
|
const ext = originalName.includes('.') ? originalName.split('.').pop() : 'png' |
|
|
|
|
|
return `${nameWithoutExt}_part${segmentIndex + 1}of${totalSegments}.${ext}` |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getProcessingStats(segments) { |
|
|
if (!segments || segments.length === 0) { |
|
|
return { totalSegments: 0, isLongImage: false } |
|
|
} |
|
|
|
|
|
const firstSegment = segments[0] |
|
|
const metadata = firstSegment.metadata |
|
|
|
|
|
return { |
|
|
totalSegments: segments.length, |
|
|
isLongImage: segments.length > 1, |
|
|
originalDimensions: { |
|
|
width: metadata.originalWidth, |
|
|
height: metadata.originalHeight |
|
|
}, |
|
|
segmentDimensions: segments.map(segment => ({ |
|
|
width: segment.metadata.segmentWidth, |
|
|
height: segment.metadata.segmentHeight, |
|
|
index: segment.metadata.segmentIndex |
|
|
})) |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
module.exports = new ImageProcessor() |
|
|
|