gongxiangp2/lib/performance-survey-processed-images.ts

224 lines
8.1 KiB
TypeScript

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<string, unknown>
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<string>()
const groups = new Map<number, PerformanceSurveyProcessedImageDeclaration[]>()
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 图片尺寸')
}