import { PERFORMANCE_SURVEY_IMAGE_LIMITS } from '@/lib/performance-survey-image-processing' export const PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS = { maxArtifactBytes: 8 * 1024 * 1024, maxBatchBytes: 256 * 1024 * 1024, maxSliceWidth: PERFORMANCE_SURVEY_IMAGE_LIMITS.maxOutputWidth, maxSliceHeight: PERFORMANCE_SURVEY_IMAGE_LIMITS.sliceHeight, maxPreviewWidth: PERFORMANCE_SURVEY_IMAGE_LIMITS.previewWidth, maxPreviewHeight: PERFORMANCE_SURVEY_IMAGE_LIMITS.previewHeight, } as const export type PerformanceSurveyProcessedImageKind = 'slice' | 'preview' export type PerformanceSurveyProcessedImageDeclaration = { uploadId: string mimeType: 'image/webp' width: number height: number kind: PerformanceSurveyProcessedImageKind sourceName: string sourceIndex: number sliceIndex: number sliceCount: number } export type PerformanceSurveyProcessedImageGroup = { sourceIndex: number sourceName: string sliceCount: number preview: PerformanceSurveyProcessedImageDeclaration | null slices: PerformanceSurveyProcessedImageDeclaration[] } export class PerformanceSurveyProcessedImageError extends Error {} function readRequiredText(value: unknown, label: string, maxLength: number) { const normalized = String(value ?? '').trim() if (!normalized) throw new PerformanceSurveyProcessedImageError(`${label}不能为空`) if (normalized.length > maxLength || /[\u0000-\u001f\u007f]/.test(normalized)) { throw new PerformanceSurveyProcessedImageError(`${label}格式无效`) } return normalized } function readInteger(value: unknown, label: string, min: number, max: number) { const normalized = Number(value) if (!Number.isInteger(normalized) || normalized < min || normalized > max) { throw new PerformanceSurveyProcessedImageError(`${label}无效`) } return normalized } function normalizeDeclaration( value: unknown, options?: { maxSliceWidth?: number }, ): PerformanceSurveyProcessedImageDeclaration { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new PerformanceSurveyProcessedImageError('预处理图片参数无效') } const input = value as Record const kind = String(input.kind || '').trim() if (kind !== 'slice' && kind !== 'preview') { throw new PerformanceSurveyProcessedImageError('预处理图片类型无效') } const mimeType = String(input.mimeType || '').trim().toLowerCase() if (mimeType !== 'image/webp') { throw new PerformanceSurveyProcessedImageError('预处理图片只接受 WebP 格式') } const maxWidth = kind === 'preview' ? PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxPreviewWidth : options?.maxSliceWidth || PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxSliceWidth const maxHeight = kind === 'preview' ? PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxPreviewHeight : PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxSliceHeight return { uploadId: readRequiredText(input.uploadId, '图片上传凭证', 128), mimeType: 'image/webp', width: readInteger(input.width, '图片宽度', 1, maxWidth), height: readInteger(input.height, '图片高度', 1, maxHeight), kind, sourceName: readRequiredText(input.sourceName, '源文件名', 255), sourceIndex: readInteger( input.sourceIndex, '源文件序号', 0, PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount - 1, ), sliceIndex: readInteger( input.sliceIndex, '切片序号', 0, PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount - 1, ), sliceCount: readInteger( input.sliceCount, '切片数量', 1, PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount, ), } } export function validatePerformanceSurveyProcessedImages( value: unknown, options?: { maxSliceWidth?: number }, ): PerformanceSurveyProcessedImageGroup[] { if (!Array.isArray(value) || value.length === 0) { throw new PerformanceSurveyProcessedImageError('每个案例至少上传 1 张预处理图片') } if (value.length > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount * 2) { throw new PerformanceSurveyProcessedImageError('预处理图片数量过多') } const declarations = value.map((item) => normalizeDeclaration(item, options)) const uploadIds = new Set() const groups = new Map() for (const declaration of declarations) { if (uploadIds.has(declaration.uploadId)) { throw new PerformanceSurveyProcessedImageError('图片上传凭证重复') } uploadIds.add(declaration.uploadId) const current = groups.get(declaration.sourceIndex) || [] current.push(declaration) groups.set(declaration.sourceIndex, current) } const sourceIndexes = Array.from(groups.keys()).sort((a, b) => a - b) if (sourceIndexes.length > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount) { throw new PerformanceSurveyProcessedImageError( `单个案例最多上传 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount} 张源图片`, ) } sourceIndexes.forEach((sourceIndex, index) => { if (sourceIndex !== index) { throw new PerformanceSurveyProcessedImageError('源文件序号必须从 0 开始且连续') } }) let totalSliceCount = 0 return sourceIndexes.map((sourceIndex) => { const records = groups.get(sourceIndex) || [] const sourceNames = new Set(records.map((item) => item.sourceName)) const sliceCounts = new Set(records.map((item) => item.sliceCount)) if (sourceNames.size !== 1 || sliceCounts.size !== 1) { throw new PerformanceSurveyProcessedImageError('同一源文件的名称或切片数量不一致') } const previews = records.filter((item) => item.kind === 'preview') const slices = records.filter((item) => item.kind === 'slice').sort((a, b) => a.sliceIndex - b.sliceIndex) const sliceCount = records[0].sliceCount if (previews.length > 1) { throw new PerformanceSurveyProcessedImageError(`源文件「${records[0].sourceName}」只能有 1 张预览图`) } if (previews[0] && previews[0].sliceIndex !== 0) { throw new PerformanceSurveyProcessedImageError(`源文件「${records[0].sourceName}」预览图序号无效`) } if (slices.length !== sliceCount) { throw new PerformanceSurveyProcessedImageError(`源文件「${records[0].sourceName}」切片不完整`) } slices.forEach((slice, index) => { if (slice.sliceIndex !== index) { throw new PerformanceSurveyProcessedImageError(`源文件「${records[0].sourceName}」切片序号不连续`) } }) totalSliceCount += slices.length if (totalSliceCount > PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount) { throw new PerformanceSurveyProcessedImageError( `单个案例最多上传 ${PERFORMANCE_SURVEY_IMAGE_LIMITS.maxSourceCount} 张有序切片`, ) } return { sourceIndex, sourceName: records[0].sourceName, sliceCount, preview: previews[0] || null, slices, } }) } function readUInt24LE(buffer: Buffer, offset: number) { return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) } export function readPerformanceSurveyWebpDimensions(buffer: Buffer) { if (buffer.length < 30 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WEBP') { throw new PerformanceSurveyProcessedImageError('预处理图片不是有效的 WebP 文件') } const chunkType = buffer.toString('ascii', 12, 16) if (chunkType === 'VP8X') { return { width: readUInt24LE(buffer, 24) + 1, height: readUInt24LE(buffer, 27) + 1, } } if (chunkType === 'VP8L' && buffer[20] === 0x2f) { const b1 = buffer[21] const b2 = buffer[22] const b3 = buffer[23] const b4 = buffer[24] return { width: 1 + (b1 | ((b2 & 0x3f) << 8)), height: 1 + ((b2 >> 6) | (b3 << 2) | ((b4 & 0x0f) << 10)), } } if (chunkType === 'VP8 ' && buffer[23] === 0x9d && buffer[24] === 0x01 && buffer[25] === 0x2a) { return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff, } } throw new PerformanceSurveyProcessedImageError('无法读取预处理 WebP 图片尺寸') }