224 lines
8.1 KiB
TypeScript
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 图片尺寸')
|
|
}
|