189 lines
5.7 KiB
TypeScript
189 lines
5.7 KiB
TypeScript
/**
|
||
* 图片处理工具函数
|
||
* 用于压缩和切片图片
|
||
*/
|
||
|
||
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
|
||
}
|
||
|