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} 张`, ) } }