feat: implement client-side image compression with Canvas API
- Add imageResize utility using native Canvas API - Compress images to max 1024x1024px before upload - Support contain mode for proportional scaling - Integrate compression into file upload, drag-drop, and URL loading flows - Optimize memory management with proper URL cleanup - Skip compression for images already below target size
This commit is contained in:
parent
c3d9263aac
commit
bbf4527a12
184
apps/jingrow/frontend/src/shared/utils/imageResize.ts
Normal file
184
apps/jingrow/frontend/src/shared/utils/imageResize.ts
Normal file
@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 图片压缩工具 - 使用原生 Canvas API
|
||||
* 将图片压缩到指定最大尺寸(1024x1024px)
|
||||
*/
|
||||
|
||||
export interface ResizeOptions {
|
||||
maxWidth?: number
|
||||
maxHeight?: number
|
||||
quality?: number
|
||||
outputFormat?: 'image/jpeg' | 'image/png' | 'image/webp'
|
||||
mode?: 'contain' | 'cover' | 'crop' // contain: 等比缩放, cover: 填充裁剪, crop: 居中裁剪
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<ResizeOptions> = {
|
||||
maxWidth: 1024,
|
||||
maxHeight: 1024,
|
||||
quality: 0.92,
|
||||
outputFormat: 'image/jpeg',
|
||||
mode: 'contain'
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Canvas API 压缩图片
|
||||
* @param file 原始图片文件
|
||||
* @param options 压缩选项
|
||||
* @returns 压缩后的 Blob
|
||||
*/
|
||||
export async function resizeImageWithCanvas(
|
||||
file: File,
|
||||
options: ResizeOptions = {}
|
||||
): Promise<Blob> {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options }
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error('Canvas context not available'))
|
||||
return
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
let { width, height } = img
|
||||
|
||||
// 如果图片尺寸已经小于等于目标尺寸,直接返回原图
|
||||
if (width <= opts.maxWidth && height <= opts.maxHeight) {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
// 直接读取原文件为 blob
|
||||
file.arrayBuffer().then(buffer => {
|
||||
resolve(new Blob([buffer], { type: file.type }))
|
||||
}).catch(reject)
|
||||
return
|
||||
}
|
||||
|
||||
// 计算目标尺寸
|
||||
let targetWidth = width
|
||||
let targetHeight = height
|
||||
let sourceX = 0
|
||||
let sourceY = 0
|
||||
let sourceWidth = width
|
||||
let sourceHeight = height
|
||||
|
||||
if (opts.mode === 'contain') {
|
||||
// 等比缩放,保持宽高比
|
||||
const scale = Math.min(
|
||||
opts.maxWidth / width,
|
||||
opts.maxHeight / height,
|
||||
1 // 不放大
|
||||
)
|
||||
targetWidth = Math.round(width * scale)
|
||||
targetHeight = Math.round(height * scale)
|
||||
} else if (opts.mode === 'cover') {
|
||||
// 填充模式:裁剪以填满目标尺寸
|
||||
const scale = Math.max(
|
||||
opts.maxWidth / width,
|
||||
opts.maxHeight / height
|
||||
)
|
||||
targetWidth = opts.maxWidth
|
||||
targetHeight = opts.maxHeight
|
||||
sourceWidth = Math.round(opts.maxWidth / scale)
|
||||
sourceHeight = Math.round(opts.maxHeight / scale)
|
||||
sourceX = Math.round((width - sourceWidth) / 2)
|
||||
sourceY = Math.round((height - sourceHeight) / 2)
|
||||
} else if (opts.mode === 'crop') {
|
||||
// 居中裁剪到目标尺寸
|
||||
const scale = Math.max(
|
||||
opts.maxWidth / width,
|
||||
opts.maxHeight / height
|
||||
)
|
||||
const scaledWidth = width * scale
|
||||
const scaledHeight = height * scale
|
||||
targetWidth = opts.maxWidth
|
||||
targetHeight = opts.maxHeight
|
||||
sourceWidth = width
|
||||
sourceHeight = height
|
||||
// 计算裁剪区域
|
||||
if (scaledWidth > opts.maxWidth) {
|
||||
sourceX = Math.round((scaledWidth - opts.maxWidth) / 2 / scale)
|
||||
sourceWidth = Math.round(opts.maxWidth / scale)
|
||||
}
|
||||
if (scaledHeight > opts.maxHeight) {
|
||||
sourceY = Math.round((scaledHeight - opts.maxHeight) / 2 / scale)
|
||||
sourceHeight = Math.round(opts.maxHeight / scale)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置 canvas 尺寸
|
||||
canvas.width = targetWidth
|
||||
canvas.height = targetHeight
|
||||
|
||||
// 设置图片质量(仅对 JPEG 有效)
|
||||
if (opts.outputFormat === 'image/jpeg') {
|
||||
ctx.fillStyle = '#FFFFFF'
|
||||
ctx.fillRect(0, 0, targetWidth, targetHeight)
|
||||
}
|
||||
|
||||
// 绘制图片
|
||||
ctx.drawImage(
|
||||
img,
|
||||
sourceX, sourceY, sourceWidth, sourceHeight,
|
||||
0, 0, targetWidth, targetHeight
|
||||
)
|
||||
|
||||
// 转换为 Blob
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
// 清理对象 URL
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
} else {
|
||||
reject(new Error('Failed to create blob'))
|
||||
}
|
||||
},
|
||||
opts.outputFormat,
|
||||
opts.quality
|
||||
)
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
reject(new Error('Failed to load image'))
|
||||
}
|
||||
|
||||
// 加载图片
|
||||
img.src = objectUrl
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 压缩图片文件,返回新的 File 对象
|
||||
*/
|
||||
export async function compressImageFile(
|
||||
file: File,
|
||||
options: ResizeOptions = {}
|
||||
): Promise<File> {
|
||||
const blob = await resizeImageWithCanvas(file, options)
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options }
|
||||
|
||||
// 确定文件扩展名
|
||||
let ext = 'jpg'
|
||||
if (opts.outputFormat === 'image/png') {
|
||||
ext = 'png'
|
||||
} else if (opts.outputFormat === 'image/webp') {
|
||||
ext = 'webp'
|
||||
}
|
||||
|
||||
// 创建新的 File 对象,保持原始文件名但更新扩展名
|
||||
const fileName = file.name.replace(/\.[^.]+$/, '') + '.' + ext
|
||||
return new File([blob], fileName, {
|
||||
type: opts.outputFormat,
|
||||
lastModified: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import { t } from '@/shared/i18n'
|
||||
import { useAuthStore } from '@/shared/stores/auth'
|
||||
import { signupApi } from '@/shared/api/auth'
|
||||
import UserMenu from '@/shared/components/UserMenu.vue'
|
||||
import { compressImageFile } from '@/shared/utils/imageResize'
|
||||
import axios from 'axios'
|
||||
|
||||
const message = useMessage()
|
||||
@ -285,7 +286,7 @@ const handleFileSelect = (e: Event) => {
|
||||
}
|
||||
}
|
||||
|
||||
const processFile = (file: File) => {
|
||||
const processFile = async (file: File) => {
|
||||
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
||||
if (!validTypes.includes(file.type)) {
|
||||
message.warning('不支持的图片格式,请使用 JPG、PNG 或 WebP')
|
||||
@ -298,17 +299,29 @@ const processFile = (file: File) => {
|
||||
return
|
||||
}
|
||||
|
||||
uploadedImage.value = file
|
||||
resultImage.value = ''
|
||||
splitPosition.value = 0
|
||||
currentHistoryIndex.value = -1
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
uploadedImageUrl.value = e.target?.result as string
|
||||
handleRemoveBackground()
|
||||
try {
|
||||
const compressedFile = await compressImageFile(file, {
|
||||
maxWidth: 1024,
|
||||
maxHeight: 1024,
|
||||
quality: 0.92,
|
||||
mode: 'contain'
|
||||
})
|
||||
|
||||
uploadedImage.value = compressedFile
|
||||
resultImage.value = ''
|
||||
splitPosition.value = 0
|
||||
currentHistoryIndex.value = -1
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
uploadedImageUrl.value = e.target?.result as string
|
||||
handleRemoveBackground()
|
||||
}
|
||||
reader.readAsDataURL(compressedFile)
|
||||
} catch (error) {
|
||||
console.error('图片压缩失败:', error)
|
||||
message.error('图片处理失败,请重试')
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const handleRemoveBackground = async () => {
|
||||
@ -621,12 +634,26 @@ const handleUrlSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const file = new File([blob], 'image-from-url', { type: blob.type })
|
||||
uploadedImage.value = file
|
||||
const originalFile = new File([blob], 'image-from-url', { type: blob.type })
|
||||
|
||||
uploadedImageUrl.value = URL.createObjectURL(blob)
|
||||
|
||||
await handleRemoveBackground()
|
||||
// 压缩图片到 1024x1024
|
||||
try {
|
||||
const compressedFile = await compressImageFile(originalFile, {
|
||||
maxWidth: 1024,
|
||||
maxHeight: 1024,
|
||||
quality: 0.92,
|
||||
mode: 'contain' // 等比缩放
|
||||
})
|
||||
|
||||
uploadedImage.value = compressedFile
|
||||
uploadedImageUrl.value = URL.createObjectURL(compressedFile)
|
||||
|
||||
await handleRemoveBackground()
|
||||
} catch (error) {
|
||||
console.error('图片压缩失败:', error)
|
||||
message.error('图片处理失败,请重试')
|
||||
processing.value = false
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载图片URL失败:', error)
|
||||
let errorMessage = '加载图片URL失败'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user