import sharp from 'sharp' import { configureSharpRuntime } from '@/lib/sharp-runtime' configureSharpRuntime() 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((resolve) => performanceSurveyImageJobQueue.push(resolve)) } function releasePerformanceSurveyImageJob() { const next = performanceSurveyImageJobQueue.shift() if (next) { next() return } performanceSurveyImageJobActive = false } export async function withPerformanceSurveyImageProcessingSlot(task: () => Promise): Promise { 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 { 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 { 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, ): Promise { 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 { 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 { 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>() 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, } }