gongxiangp2/components/upload/image-processor.ts
2026-08-07 23:28:55 +08:00

189 lines
5.7 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 {
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<ArrayBuffer> {
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<void> {
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<HTMLImageElement> {
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<File> {
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<File> {
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<File> {
await assertVideoPreviewImageIsSafeToDecode(file)
return compressPreviewImage(file)
}
/**
* 展示图:压缩 + 切片,高度>1500则按 1500px 一段切
*/
export async function compressAndSliceDisplayImage(file: File): Promise<File[]> {
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
}