157 lines
6.5 KiB
TypeScript
157 lines
6.5 KiB
TypeScript
import {
|
||
PerformanceSurveyBrowserImageError,
|
||
type PerformanceSurveyBrowserImageErrorCode,
|
||
} from './performance-survey-image-error'
|
||
|
||
export const PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS = {
|
||
maxWidth: 12_000,
|
||
// 视觉评分的源图通常是 1600px 左右的长图。这里允许 1600×75,000 这类长图,
|
||
// 但仍在浏览器解码前挡住会显著拖垮员工设备的超大像素文件。
|
||
maxPixels: 120_000_000,
|
||
maxOutputWidth: 1_600,
|
||
sliceHeight: 1_500,
|
||
maxSlicesPerCase: 60,
|
||
headerReadBytes: 256 * 1024,
|
||
// 少数 JPEG 的 EXIF/ICC 段会超过首段读取范围;扩大到 4MB 仍远小于解码整图的成本,
|
||
// 用于在浏览器解码前继续拦截超大像素图片。
|
||
extendedHeaderReadBytes: 4 * 1024 * 1024,
|
||
} as const
|
||
|
||
// 任务详情在 Edge 预处理失败时会交给服务器 Sharp 兜底;两端必须使用同一
|
||
// 像素门禁,否则 60M~120M 的图片会在上传完成后才后台失败。
|
||
export const TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS = 60_000_000
|
||
|
||
export type PerformanceSurveySourceImageDimensions = {
|
||
width: number
|
||
height: number
|
||
}
|
||
|
||
export class PerformanceSurveySourceImageError extends PerformanceSurveyBrowserImageError {
|
||
constructor(code: PerformanceSurveyBrowserImageErrorCode, message: string) {
|
||
super(code, message)
|
||
this.name = 'PerformanceSurveySourceImageError'
|
||
Object.setPrototypeOf(this, PerformanceSurveySourceImageError.prototype)
|
||
}
|
||
}
|
||
|
||
function readUInt16BE(bytes: Uint8Array, offset: number) {
|
||
return (bytes[offset] << 8) | bytes[offset + 1]
|
||
}
|
||
|
||
function readUInt16LE(bytes: Uint8Array, offset: number) {
|
||
return bytes[offset] | (bytes[offset + 1] << 8)
|
||
}
|
||
|
||
function readUInt24LE(bytes: Uint8Array, offset: number) {
|
||
return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16)
|
||
}
|
||
|
||
function startsWithAscii(bytes: Uint8Array, text: string, offset = 0) {
|
||
if (bytes.length < offset + text.length) return false
|
||
return Array.from(text).every((character, index) => bytes[offset + index] === character.charCodeAt(0))
|
||
}
|
||
|
||
function readPngDimensions(bytes: Uint8Array): PerformanceSurveySourceImageDimensions | null {
|
||
const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
||
if (bytes.length < 24 || !pngSignature.every((value, index) => bytes[index] === value)) return null
|
||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||
return { width: view.getUint32(16), height: view.getUint32(20) }
|
||
}
|
||
|
||
function readGifDimensions(bytes: Uint8Array): PerformanceSurveySourceImageDimensions | null {
|
||
if (bytes.length < 10 || (!startsWithAscii(bytes, 'GIF87a') && !startsWithAscii(bytes, 'GIF89a'))) return null
|
||
return { width: readUInt16LE(bytes, 6), height: readUInt16LE(bytes, 8) }
|
||
}
|
||
|
||
function readWebpDimensions(bytes: Uint8Array): PerformanceSurveySourceImageDimensions | null {
|
||
if (bytes.length < 30 || !startsWithAscii(bytes, 'RIFF') || !startsWithAscii(bytes, 'WEBP', 8)) return null
|
||
const chunkType = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15])
|
||
if (chunkType === 'VP8X') {
|
||
return { width: readUInt24LE(bytes, 24) + 1, height: readUInt24LE(bytes, 27) + 1 }
|
||
}
|
||
if (chunkType === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
|
||
const b1 = bytes[21]
|
||
const b2 = bytes[22]
|
||
const b3 = bytes[23]
|
||
const b4 = bytes[24]
|
||
return {
|
||
width: 1 + (b1 | ((b2 & 0x3f) << 8)),
|
||
height: 1 + ((b2 >> 6) | (b3 << 2) | ((b4 & 0x0f) << 10)),
|
||
}
|
||
}
|
||
if (chunkType === 'VP8 ' && bytes.length >= 30 && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) {
|
||
return { width: readUInt16LE(bytes, 26) & 0x3fff, height: readUInt16LE(bytes, 28) & 0x3fff }
|
||
}
|
||
return null
|
||
}
|
||
|
||
function readJpegDimensions(bytes: Uint8Array): PerformanceSurveySourceImageDimensions | null {
|
||
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null
|
||
let offset = 2
|
||
while (offset + 9 < bytes.length) {
|
||
if (bytes[offset] !== 0xff) {
|
||
offset += 1
|
||
continue
|
||
}
|
||
while (bytes[offset] === 0xff) offset += 1
|
||
const marker = bytes[offset]
|
||
offset += 1
|
||
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) continue
|
||
if (offset + 1 >= bytes.length) return null
|
||
const segmentLength = readUInt16BE(bytes, offset)
|
||
if (segmentLength < 2 || offset + segmentLength > bytes.length) return null
|
||
const isStartOfFrame = marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)
|
||
if (isStartOfFrame) {
|
||
return {
|
||
height: readUInt16BE(bytes, offset + 3),
|
||
width: readUInt16BE(bytes, offset + 5),
|
||
}
|
||
}
|
||
offset += segmentLength
|
||
}
|
||
return null
|
||
}
|
||
|
||
export function readPerformanceSurveySourceImageDimensions(bytes: Uint8Array) {
|
||
return readPngDimensions(bytes)
|
||
|| readJpegDimensions(bytes)
|
||
|| readGifDimensions(bytes)
|
||
|| readWebpDimensions(bytes)
|
||
}
|
||
|
||
export function assertPerformanceSurveyBrowserSourceImageDimensions(
|
||
dimensions: PerformanceSurveySourceImageDimensions | null,
|
||
sourceName: string,
|
||
options: { maxPixels?: number } = {},
|
||
) {
|
||
if (!dimensions || !Number.isInteger(dimensions.width) || !Number.isInteger(dimensions.height)
|
||
|| dimensions.width <= 0 || dimensions.height <= 0) {
|
||
throw new PerformanceSurveySourceImageError(
|
||
'SOURCE_DIMENSIONS_INVALID',
|
||
`图片「${sourceName}」无法在解码前读取尺寸,请使用 PNG、JPG、GIF 或 WebP 格式`,
|
||
)
|
||
}
|
||
if (dimensions.width > PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxWidth) {
|
||
throw new PerformanceSurveySourceImageError(
|
||
'SOURCE_WIDTH_LIMIT',
|
||
`图片「${sourceName}」宽度超过 ${PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxWidth}px`,
|
||
)
|
||
}
|
||
const maxPixels = options.maxPixels || PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels
|
||
if (dimensions.width * dimensions.height > maxPixels) {
|
||
throw new PerformanceSurveySourceImageError(
|
||
'SOURCE_PIXEL_LIMIT',
|
||
`图片「${sourceName}」像素总量超过 ${maxPixels.toLocaleString()},请先压缩或切图`,
|
||
)
|
||
}
|
||
const outputScale = Math.min(1, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxOutputWidth / dimensions.width)
|
||
const outputHeight = Math.max(1, Math.round(dimensions.height * outputScale))
|
||
const sliceCount = Math.ceil(outputHeight / PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.sliceHeight)
|
||
if (sliceCount > PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxSlicesPerCase) {
|
||
throw new PerformanceSurveySourceImageError(
|
||
'SOURCE_SLICE_LIMIT',
|
||
`图片「${sourceName}」处理后需要 ${sliceCount} 张切片,单个案例最多支持 ${PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxSlicesPerCase} 张`,
|
||
)
|
||
}
|
||
}
|