/** * 图片处理工具函数 * 用于压缩和切片图片 */ import { PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS, readPerformanceSurveySourceImageDimensions, } from '@/lib/performance-survey-image-source-guard' const VIDEO_PREVIEW_MAX_INPUT_PIXELS = 60_000_000 const VIDEO_PREVIEW_MAX_INPUT_DIMENSION = 12_000 async function readBlobAsArrayBuffer(blob: Blob): Promise { if (typeof blob.arrayBuffer === 'function') { return blob.arrayBuffer() } return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(reader.result as ArrayBuffer) reader.onerror = () => reject(reader.error || new Error('图片文件读取失败')) reader.readAsArrayBuffer(blob) }) } async function readImageDimensionsBeforeDecode(file: File) { const firstHeader = new Uint8Array(await readBlobAsArrayBuffer(file.slice( 0, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.headerReadBytes, ))) let dimensions = readPerformanceSurveySourceImageDimensions(firstHeader) if (!dimensions && file.size > firstHeader.byteLength) { const extendedHeader = new Uint8Array(await readBlobAsArrayBuffer(file.slice( 0, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.extendedHeaderReadBytes, ))) dimensions = readPerformanceSurveySourceImageDimensions(extendedHeader) } return dimensions } export async function assertVideoPreviewImageIsSafeToDecode(file: File): Promise { const dimensions = await readImageDimensionsBeforeDecode(file) if (!dimensions || dimensions.width <= 0 || dimensions.height <= 0) { throw new Error(`无法读取预览图「${file.name}」的尺寸,请改用 JPG、PNG 或 WebP 图片`) } if ( dimensions.width > VIDEO_PREVIEW_MAX_INPUT_DIMENSION || dimensions.height > VIDEO_PREVIEW_MAX_INPUT_DIMENSION ) { throw new Error(`预览图「${file.name}」的边长不能超过 ${VIDEO_PREVIEW_MAX_INPUT_DIMENSION}px`) } if (dimensions.width * dimensions.height > VIDEO_PREVIEW_MAX_INPUT_PIXELS) { throw new Error(`预览图「${file.name}」像素总量超过 60,000,000,请先压缩图片`) } } /** * 加载图片文件为HTMLImageElement */ export function loadImage(file: File): Promise { return new Promise((resolve, reject) => { if (typeof window === 'undefined') { reject(new Error('仅在浏览器环境中支持图片处理')) return } const img = window.document.createElement('img') img.onload = () => { URL.revokeObjectURL(img.src) resolve(img) } img.onerror = (err) => { URL.revokeObjectURL(img.src) reject(err) } img.src = URL.createObjectURL(file) }) } /** * 将Canvas转换为File */ export function canvasToFile( canvas: HTMLCanvasElement, origin: File, quality = 0.85, suffix = '' ): Promise { return new Promise((resolve, reject) => { canvas.toBlob( (blob) => { if (!blob) { reject(new Error('图片压缩失败')) return } const baseName = origin.name.replace(/\.[^/.]+$/, '') const ext = '.jpg' const fileName = suffix ? `${baseName}${suffix}${ext}` : `${baseName}${ext}` resolve(new File([blob], fileName, { type: 'image/jpeg' })) }, 'image/jpeg', quality ) }) } /** * 预览图:只做压缩,不切片 */ export async function compressPreviewImage(file: File): Promise { const img = await loadImage(file) const maxWidth = 800 const maxHeight = 2000 let { width, height } = img const ratio = Math.min(maxWidth / width, maxHeight / height, 1) const canvas = document.createElement('canvas') canvas.width = Math.round(width * ratio) canvas.height = Math.round(height * ratio) const ctx = canvas.getContext('2d') if (!ctx) throw new Error('无法获取画布上下文') ctx.drawImage(img, 0, 0, canvas.width, canvas.height) return await canvasToFile(canvas, file, 0.85) } export async function compressVideoPreviewImage(file: File): Promise { await assertVideoPreviewImageIsSafeToDecode(file) return compressPreviewImage(file) } /** * 展示图:压缩 + 切片,高度>1500则按 1500px 一段切 */ export async function compressAndSliceDisplayImage(file: File): Promise { const img = await loadImage(file) const maxWidth = 800 const maxHeight = 2000 let { width, height } = img const ratio = Math.min(maxWidth / width, maxHeight / height, 1) const targetWidth = Math.round(width * ratio) const targetHeight = Math.round(height * ratio) const sliceMaxHeight = 1500 const results: File[] = [] if (targetHeight <= sliceMaxHeight) { const canvas = document.createElement('canvas') canvas.width = targetWidth canvas.height = targetHeight const ctx = canvas.getContext('2d') if (!ctx) throw new Error('无法获取画布上下文') ctx.drawImage(img, 0, 0, targetWidth, targetHeight) const f = await canvasToFile(canvas, file, 0.85) results.push(f) return results } // 需要切片 const sliceCount = Math.ceil(targetHeight / sliceMaxHeight) for (let i = 0; i < sliceCount; i++) { const sliceTop = i * sliceMaxHeight const sliceHeight = Math.min(sliceMaxHeight, targetHeight - sliceTop) const canvas = document.createElement('canvas') canvas.width = targetWidth canvas.height = sliceHeight const ctx = canvas.getContext('2d') if (!ctx) throw new Error('无法获取画布上下文') ctx.drawImage( img, 0, sliceTop / ratio, width, sliceHeight / ratio, 0, 0, targetWidth, sliceHeight ) const f = await canvasToFile(canvas, file, 0.85, `-part${i + 1}`) results.push(f) } return results }