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

433 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import sharp from 'sharp'
export const PERFORMANCE_SURVEY_IMAGE_LIMITS = {
maxSourceCount: 60,
maxSourceBytes: 200 * 1024 * 1024,
maxLegacyBatchSourceBytes: 60 * 1024 * 1024,
maxRequestBytes: 64 * 1024 * 1024,
maxPixelsPerImage: 60_000_000,
maxWidth: 12_000,
maxOutputWidth: 1_600,
sliceHeight: 1_500,
maxSlicesPerImage: 48,
maxSlicesPerBatch: 2_880,
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
imageId?: string
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}」超过 200MB`)
}
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.maxLegacyBatchSourceBytes) {
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,
imageId: String(item.imageId || '').trim() || undefined,
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,
}
}