gongxiangp2/lib/performance-survey-image-source-guard.ts
2026-09-16 11:27:06 +08:00

191 lines
7.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 const BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_PIXELS = 120_000_000
export const BRAND_KNOWLEDGE_BROWSER_SOURCE_MAX_HEIGHT = 90_000
export const BRAND_KNOWLEDGE_LEGACY_SERVER_MAX_PIXELS = 60_000_000
export const BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT = 3_000
export function shouldSliceBrandKnowledgePreview(sourceHeight: number) {
return sourceHeight > BRAND_KNOWLEDGE_PREVIEW_SINGLE_IMAGE_MAX_HEIGHT
}
export function planBrandKnowledgePreviewSlices(
sourceHeight: number,
outputHeight: number,
defaultSliceHeight: number,
) {
const safeOutputHeight = Math.max(1, Math.round(outputHeight))
if (!shouldSliceBrandKnowledgePreview(sourceHeight)) {
return { sliceHeightLimit: safeOutputHeight, sliceCount: 1 }
}
const naturalSliceCount = Math.ceil(safeOutputHeight / defaultSliceHeight)
const sliceHeightLimit = naturalSliceCount > 1
? defaultSliceHeight
: Math.max(1, Math.ceil(safeOutputHeight / 2))
return {
sliceHeightLimit,
sliceCount: Math.ceil(safeOutputHeight / sliceHeightLimit),
}
}
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; maxHeight?: 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`,
)
}
if (options.maxHeight && dimensions.height > options.maxHeight) {
throw new PerformanceSurveySourceImageError(
'SOURCE_DIMENSIONS_INVALID',
`图片「${sourceName}」高度超过 ${options.maxHeight}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} 张`,
)
}
}