gongxiangp2/lib/performance-survey-image-processing.ts

431 lines
15 KiB
TypeScript

import sharp from 'sharp'
export const PERFORMANCE_SURVEY_IMAGE_LIMITS = {
maxSourceCount: 12,
maxSourceBytes: 20 * 1024 * 1024,
maxBatchSourceBytes: 60 * 1024 * 1024,
maxRequestBytes: 64 * 1024 * 1024,
maxPixelsPerImage: 40_000_000,
maxWidth: 12_000,
maxOutputWidth: 1_600,
sliceHeight: 1_500,
maxSlicesPerImage: 36,
maxSlicesPerBatch: 48,
targetSliceSizeKB: 500,
previewWidth: 720,
previewHeight: 560,
maxQueuedJobs: 3,
} as const
export type PerformanceSurveyImageInput = {
buffer: Buffer
mimeType: string
sourceName: string
}
export type PerformanceSurveyImagePlan = {
sourceIndex: number
sourceName: string
sourceWidth: number
sourceHeight: number
outputWidth: number
outputHeight: number
scale: number
sliceCount: number
}
export type ProcessedPerformanceSurveyImage = {
buffer: Buffer
sourceIndex: number
sliceIndex: number
sliceCount: number
sourceName: string
width: number
height: number
}
export type ProcessedPerformanceSurveyImageSource = {
plan: PerformanceSurveyImagePlan
preview: {
buffer: Buffer
width: number
height: number
}
slices: ProcessedPerformanceSurveyImage[]
}
export type PerformanceSurveyStoredImageSlice = {
url: string
width: number
height: number
}
export type PerformanceSurveyStoredImageGroup = {
version: 1
sourceName: string
sourceWidth: number
sourceHeight: number
width: number
height: number
previewUrl: string
previewWidth: number
previewHeight: number
slices: PerformanceSurveyStoredImageSlice[]
}
export type PerformanceSurveyImagePayload = {
imageUrls: string[]
imageGroups: PerformanceSurveyStoredImageGroup[]
}
export class PerformanceSurveyImageProcessingError extends Error {}
let performanceSurveyImageJobActive = false
const performanceSurveyImageJobQueue: Array<() => void> = []
async function acquirePerformanceSurveyImageJob() {
if (!performanceSurveyImageJobActive) {
performanceSurveyImageJobActive = true
return
}
if (performanceSurveyImageJobQueue.length >= PERFORMANCE_SURVEY_IMAGE_LIMITS.maxQueuedJobs) {
throw new PerformanceSurveyImageProcessingError('当前图片处理任务较多,请稍后重试')
}
await new Promise<void>((resolve) => performanceSurveyImageJobQueue.push(resolve))
}
function releasePerformanceSurveyImageJob() {
const next = performanceSurveyImageJobQueue.shift()
if (next) {
next()
return
}
performanceSurveyImageJobActive = false
}
export async function withPerformanceSurveyImageProcessingSlot<T>(task: () => Promise<T>): Promise<T> {
await acquirePerformanceSurveyImageJob()
try {
return await task()
} finally {
releasePerformanceSurveyImageJob()
}
}
export function assertPerformanceSurveyImageRequestSize(contentLength: string | null) {
if (!contentLength) return
const bytes = Number(contentLength)
if (!Number.isFinite(bytes) || bytes <= 0) return
if (bytes > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxRequestBytes) {
throw new PerformanceSurveyImageProcessingError('单个案例上传总大小不能超过 60MB')
}
}
export function getPerformanceSurveyImageSliceCount(height: number) {
return Math.ceil(height / PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight)
}
export function assertPerformanceSurveyImageDimensions(width: number, height: number, sourceName = '图片') {
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
throw new PerformanceSurveyImageProcessingError(`图片「${sourceName}」尺寸无效`)
}
if (width > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxWidth) {
throw new PerformanceSurveyImageProcessingError(
`图片「${sourceName}」宽度超过 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxWidth}px`,
)
}
if (width * height > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxPixelsPerImage) {
throw new PerformanceSurveyImageProcessingError(`图片「${sourceName}」像素总量过大`)
}
const sliceCount = getPerformanceSurveyImageSliceCount(height)
if (sliceCount > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSlicesPerImage) {
throw new PerformanceSurveyImageProcessingError(
`图片「${sourceName}」过长,最多允许 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSlicesPerImage} 个切片`,
)
}
return sliceCount
}
export async function inspectPerformanceSurveyImage(
input: PerformanceSurveyImageInput,
sourceIndex = 0,
): Promise<PerformanceSurveyImagePlan> {
if (!input.mimeType.startsWith('image/')) {
throw new PerformanceSurveyImageProcessingError(`文件「${input.sourceName}」不是图片`)
}
if (input.buffer.length > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceBytes) {
throw new PerformanceSurveyImageProcessingError(`图片「${input.sourceName}」超过 20MB`)
}
try {
const metadata = await sharp(input.buffer, {
limitInputPixels: PERFORMANCE_SURVEY_IMAGE_LIMITS.maxPixelsPerImage,
sequentialRead: true,
failOn: 'error',
}).metadata()
if (!metadata.width || !metadata.height) {
throw new PerformanceSurveyImageProcessingError(`图片「${input.sourceName}」尺寸无效`)
}
if (Number(metadata.pages || 1) > 1) {
throw new PerformanceSurveyImageProcessingError(`图片「${input.sourceName}」不支持动图或多页图片`)
}
assertPerformanceSurveyImageDimensions(metadata.width, metadata.height, input.sourceName)
const scale = Math.min(1, PERFORMANCE_SURVEY_IMAGE_LIMITS.maxOutputWidth / metadata.width)
const outputWidth = Math.max(1, Math.round(metadata.width * scale))
const outputHeight = Math.max(1, Math.round(metadata.height * scale))
const sliceCount = getPerformanceSurveyImageSliceCount(outputHeight)
if (sliceCount > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSlicesPerImage) {
throw new PerformanceSurveyImageProcessingError(
`图片「${input.sourceName}」过长,最多允许 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSlicesPerImage} 个切片`,
)
}
return {
sourceIndex,
sourceName: input.sourceName,
sourceWidth: metadata.width,
sourceHeight: metadata.height,
outputWidth,
outputHeight,
scale,
sliceCount,
}
} catch (error) {
if (error instanceof PerformanceSurveyImageProcessingError) throw error
throw new PerformanceSurveyImageProcessingError(`图片「${input.sourceName}」无法读取或像素总量过大`)
}
}
export function assertPerformanceSurveyImageBatch(plans: PerformanceSurveyImagePlan[]) {
const sliceCount = plans.reduce((sum, plan) => sum + plan.sliceCount, 0)
if (sliceCount > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSlicesPerBatch) {
throw new PerformanceSurveyImageProcessingError(
`单个案例处理后最多生成 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSlicesPerBatch} 个切片`,
)
}
}
async function encodePerformanceSurveySlice(
input: PerformanceSurveyImageInput,
plan: PerformanceSurveyImagePlan,
sliceIndex: number,
): Promise<ProcessedPerformanceSurveyImage> {
const outputTop = sliceIndex * PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight
const outputHeight = Math.min(
PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight,
plan.outputHeight - outputTop,
)
const sourceTop = Math.floor(outputTop / plan.scale)
const sourceBottom = sliceIndex === plan.sliceCount - 1
? plan.sourceHeight
: Math.min(plan.sourceHeight, Math.ceil((outputTop + outputHeight) / plan.scale))
const sourceHeight = Math.max(1, sourceBottom - sourceTop)
const raw = await sharp(input.buffer, {
limitInputPixels: PERFORMANCE_SURVEY_IMAGE_LIMITS.maxPixelsPerImage,
sequentialRead: true,
failOn: 'error',
})
.extract({ left: 0, top: sourceTop, width: plan.sourceWidth, height: sourceHeight })
.resize({ width: plan.outputWidth, height: outputHeight, fit: 'fill' })
.raw()
.toBuffer({ resolveWithObject: true })
const targetBytes = PERFORMANCE_SURVEY_IMAGE_LIMITS.targetSliceSizeKB * 1024
let encoded: Buffer = Buffer.alloc(0)
for (const quality of [76, 64, 52, 42]) {
encoded = await sharp(raw.data, { raw: raw.info })
.webp({ quality, effort: 3, smartSubsample: true })
.toBuffer()
if (encoded.length <= targetBytes) break
}
return {
buffer: encoded,
sourceIndex: plan.sourceIndex,
sliceIndex,
sliceCount: plan.sliceCount,
sourceName: plan.sourceName,
width: plan.outputWidth,
height: outputHeight,
}
}
async function createPerformanceSurveyPreview(firstSlice: ProcessedPerformanceSurveyImage) {
const result = await sharp(firstSlice.buffer, {
limitInputPixels: PERFORMANCE_SURVEY_IMAGE_LIMITS.maxPixelsPerImage,
sequentialRead: true,
failOn: 'error',
})
.resize({
width: PERFORMANCE_SURVEY_IMAGE_LIMITS.previewWidth,
height: PERFORMANCE_SURVEY_IMAGE_LIMITS.previewHeight,
fit: 'cover',
position: 'top',
withoutEnlargement: true,
})
.webp({ quality: 68, effort: 3, smartSubsample: true })
.toBuffer({ resolveWithObject: true })
return {
buffer: result.data,
width: result.info.width,
height: result.info.height,
}
}
export async function processPerformanceSurveyImage(
input: PerformanceSurveyImageInput,
plan?: PerformanceSurveyImagePlan,
onSlice?: (slice: ProcessedPerformanceSurveyImage) => Promise<void> | void,
): Promise<ProcessedPerformanceSurveyImageSource> {
const inspected = plan || await inspectPerformanceSurveyImage(input)
const slices: ProcessedPerformanceSurveyImage[] = []
let firstSlice: ProcessedPerformanceSurveyImage | null = null
let processedCount = 0
for (let sliceIndex = 0; sliceIndex < inspected.sliceCount; sliceIndex += 1) {
const slice = await encodePerformanceSurveySlice(input, inspected, sliceIndex)
firstSlice ||= slice
processedCount += 1
if (onSlice) {
await onSlice(slice)
} else {
slices.push(slice)
}
}
if (processedCount !== inspected.sliceCount || !firstSlice) {
throw new PerformanceSurveyImageProcessingError(`图片「${input.sourceName}」切片数量异常`)
}
return {
plan: inspected,
preview: await createPerformanceSurveyPreview(firstSlice),
slices,
}
}
export async function processPerformanceSurveyImages(
inputs: PerformanceSurveyImageInput[],
): Promise<ProcessedPerformanceSurveyImage[]> {
if (inputs.length === 0) {
throw new PerformanceSurveyImageProcessingError('每个案例至少上传 1 张图片')
}
if (inputs.length > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount) {
throw new PerformanceSurveyImageProcessingError(
`单个案例最多上传 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount} 张图片`,
)
}
const totalBytes = inputs.reduce((sum, input) => sum + input.buffer.length, 0)
if (totalBytes > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxBatchSourceBytes) {
throw new PerformanceSurveyImageProcessingError('单个案例上传总大小不能超过 60MB')
}
const plans: PerformanceSurveyImagePlan[] = []
for (let sourceIndex = 0; sourceIndex < inputs.length; sourceIndex += 1) {
plans.push(await inspectPerformanceSurveyImage(inputs[sourceIndex], sourceIndex))
}
assertPerformanceSurveyImageBatch(plans)
const output: ProcessedPerformanceSurveyImage[] = []
for (let sourceIndex = 0; sourceIndex < inputs.length; sourceIndex += 1) {
const processed = await processPerformanceSurveyImage(inputs[sourceIndex], plans[sourceIndex])
output.push(...processed.slices)
}
return output
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function normalizeStoredSlice(value: unknown, fallbackWidth: number): PerformanceSurveyStoredImageSlice | null {
if (typeof value === 'string') {
const url = value.trim()
return url ? { url, width: fallbackWidth, height: PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight } : null
}
if (!isRecord(value)) return null
const url = String(value.url || '').trim()
if (!url) return null
return {
url,
width: Math.max(1, Number(value.width) || fallbackWidth),
height: Math.max(1, Number(value.height) || PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight),
}
}
export function serializePerformanceSurveyImagePayload(value: unknown): PerformanceSurveyImagePayload {
if (!Array.isArray(value) || value.length === 0) return { imageUrls: [], imageGroups: [] }
const legacyUrls = value.filter((item): item is string => typeof item === 'string' && Boolean(item.trim()))
if (legacyUrls.length === value.length) {
const grouped = new Map<string, Array<{ url: string; order: number }>>()
legacyUrls.forEach((rawUrl, index) => {
const url = rawUrl.trim()
const match = url.match(/-(\d+)-(\d+)\.webp(?:\?|$)/i)
const key = match ? `source-${match[1]}` : `legacy-${index + 1}`
const items = grouped.get(key) || []
items.push({ url, order: match ? Number(match[2]) : 1 })
grouped.set(key, items)
})
const imageGroups = Array.from(grouped.entries()).map(([key, items], groupIndex) => {
const slices = items
.sort((left, right) => left.order - right.order)
.map((item) => ({
url: item.url,
width: 1_200,
height: PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight,
}))
return {
version: 1 as const,
sourceName: key.startsWith('source-') ? `历史图片 ${groupIndex + 1}` : '历史图片',
sourceWidth: 1_200,
sourceHeight: slices.length * PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight,
width: 1_200,
height: slices.length * PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight,
previewUrl: slices[0].url,
previewWidth: slices[0].width,
previewHeight: Math.min(slices[0].height, PERFORMANCE_SURVEY_IMAGE_LIMITS.previewHeight),
slices,
}
})
return {
imageUrls: imageGroups.flatMap((group) => group.slices.map((slice) => slice.url)),
imageGroups,
}
}
const imageGroups = value.flatMap((item): PerformanceSurveyStoredImageGroup[] => {
if (!isRecord(item)) return []
const width = Math.max(1, Number(item.width) || 1_200)
const slicesValue = Array.isArray(item.slices)
? item.slices
: Array.isArray(item.sliceUrls)
? item.sliceUrls
: []
const slices = slicesValue
.map((slice) => normalizeStoredSlice(slice, width))
.filter((slice): slice is PerformanceSurveyStoredImageSlice => Boolean(slice))
if (slices.length === 0) return []
const previewUrl = String(item.previewUrl || slices[0].url).trim()
return [{
version: 1,
sourceName: String(item.sourceName || '案例图片').trim() || '案例图片',
sourceWidth: Math.max(1, Number(item.sourceWidth) || width),
sourceHeight: Math.max(1, Number(item.sourceHeight) || Number(item.height) || slices.reduce((sum, slice) => sum + slice.height, 0)),
width,
height: Math.max(1, Number(item.height) || slices.reduce((sum, slice) => sum + slice.height, 0)),
previewUrl,
previewWidth: Math.max(1, Number(item.previewWidth) || Math.min(width, PERFORMANCE_SURVEY_IMAGE_LIMITS.previewWidth)),
previewHeight: Math.max(1, Number(item.previewHeight) || PERFORMANCE_SURVEY_IMAGE_LIMITS.previewHeight),
slices,
}]
})
return {
imageUrls: imageGroups.flatMap((group) => group.slices.map((slice) => slice.url)),
imageGroups,
}
}