431 lines
15 KiB
TypeScript
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,
|
|
}
|
|
}
|