mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
perf(web): downsample static account avatars (#41655)
This commit is contained in:
parent
98090d60b3
commit
02855848ea
@ -12,11 +12,11 @@ import * as React from 'react'
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ImageInput from '@/app/components/base/app-icon-picker/ImageInput'
|
import ImageInput from '@/app/components/base/app-icon-picker/ImageInput'
|
||||||
import getCroppedImg from '@/app/components/base/app-icon-picker/utils'
|
|
||||||
import Divider from '@/app/components/base/divider'
|
import Divider from '@/app/components/base/divider'
|
||||||
import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks'
|
import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks'
|
||||||
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
|
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
|
||||||
import { updateUserProfile } from '@/service/common'
|
import { updateUserProfile } from '@/service/common'
|
||||||
|
import { createCroppedAvatarImage } from './avatar-image'
|
||||||
|
|
||||||
type InputImageInfo =
|
type InputImageInfo =
|
||||||
| { file: File }
|
| { file: File }
|
||||||
@ -106,7 +106,7 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
|
|||||||
handleLocalFileUpload(inputImageInfo.file)
|
handleLocalFileUpload(inputImageInfo.file)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const blob = await getCroppedImg(
|
const blob = await createCroppedAvatarImage(
|
||||||
inputImageInfo.tempUrl,
|
inputImageInfo.tempUrl,
|
||||||
inputImageInfo.croppedAreaPixels,
|
inputImageInfo.croppedAreaPixels,
|
||||||
inputImageInfo.fileName,
|
inputImageInfo.fileName,
|
||||||
|
|||||||
@ -0,0 +1,92 @@
|
|||||||
|
import type { OnImageInput } from '@/app/components/base/app-icon-picker/ImageInput'
|
||||||
|
import type { ImageFile } from '@/types/app'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { createCroppedAvatarImage } from '../avatar-image'
|
||||||
|
import AvatarWithEdit from '../AvatarWithEdit'
|
||||||
|
|
||||||
|
type LocalFileUploaderOptions = {
|
||||||
|
onUpload: (imageFile: ImageFile) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
animatedFile: new File(['animated'], 'avatar.gif', { type: 'image/gif' }),
|
||||||
|
handleLocalFileUpload: vi.fn<(file: File) => void>(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/config', () => ({ DISABLE_UPLOAD_IMAGE_AS_ICON: false }))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/app-icon-picker/ImageInput', () => ({
|
||||||
|
default: ({ onImageInput }: { onImageInput?: OnImageInput }) => (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onImageInput?.(
|
||||||
|
true,
|
||||||
|
'blob:static-avatar',
|
||||||
|
{ x: 10, y: 20, width: 1000, height: 1000 },
|
||||||
|
'avatar.png',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Select static avatar
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onImageInput?.(false, mocks.animatedFile)}>
|
||||||
|
Select animated avatar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/image-uploader/hooks', () => ({
|
||||||
|
useLocalFileUploader: (_options: LocalFileUploaderOptions) => ({
|
||||||
|
handleLocalFileUpload: mocks.handleLocalFileUpload,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../avatar-image', () => ({
|
||||||
|
createCroppedAvatarImage: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockedCreateCroppedAvatarImage = vi.mocked(createCroppedAvatarImage)
|
||||||
|
|
||||||
|
describe('AvatarWithEdit', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uploads the bounded crop for a static avatar', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const blob = new Blob(['bounded-avatar'], { type: 'image/png' })
|
||||||
|
mockedCreateCroppedAvatarImage.mockResolvedValue(blob)
|
||||||
|
render(<AvatarWithEdit avatar={null} name="Alice" size="3xl" />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /avatar\.editAction/i }))
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Select static avatar' }))
|
||||||
|
await user.click(screen.getByRole('button', { name: /iconPicker\.ok/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedCreateCroppedAvatarImage).toHaveBeenCalledWith(
|
||||||
|
'blob:static-avatar',
|
||||||
|
{ x: 10, y: 20, width: 1000, height: 1000 },
|
||||||
|
'avatar.png',
|
||||||
|
)
|
||||||
|
expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: 'avatar.png', type: 'image/png' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the existing original-file upload path for animated avatars', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<AvatarWithEdit avatar={null} name="Alice" size="3xl" />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /avatar\.editAction/i }))
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Select animated avatar' }))
|
||||||
|
await user.click(screen.getByRole('button', { name: /iconPicker\.ok/i }))
|
||||||
|
|
||||||
|
expect(mockedCreateCroppedAvatarImage).not.toHaveBeenCalled()
|
||||||
|
expect(mocks.handleLocalFileUpload).toHaveBeenCalledWith(mocks.animatedFile)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import type { Area } from 'react-easy-crop'
|
||||||
|
import { createImage } from '@/app/components/base/app-icon-picker/utils'
|
||||||
|
import { createCroppedAvatarImage, getBoundedAvatarImageSize } from '../avatar-image'
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/app-icon-picker/utils', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('@/app/components/base/app-icon-picker/utils')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createImage: vi.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockedCreateImage = vi.mocked(createImage)
|
||||||
|
|
||||||
|
describe('avatar image', () => {
|
||||||
|
describe('getBoundedAvatarImageSize', () => {
|
||||||
|
it('downsamples a large square crop to 256 pixels', () => {
|
||||||
|
expect(getBoundedAvatarImageSize({ width: 1000, height: 1000 })).toEqual({
|
||||||
|
width: 256,
|
||||||
|
height: 256,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not upscale a crop that is already within the bound', () => {
|
||||||
|
expect(getBoundedAvatarImageSize({ width: 128, height: 128 })).toEqual({
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('draws the selected crop into a bounded high-quality canvas', async () => {
|
||||||
|
const image = {} as HTMLImageElement
|
||||||
|
const expectedBlob = new Blob(['avatar'], { type: 'image/png' })
|
||||||
|
const context = {
|
||||||
|
drawImage: vi.fn(),
|
||||||
|
imageSmoothingEnabled: false,
|
||||||
|
imageSmoothingQuality: 'low',
|
||||||
|
} as unknown as CanvasRenderingContext2D
|
||||||
|
const canvas = {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
getContext: vi.fn(() => context),
|
||||||
|
toBlob: vi.fn((callback: BlobCallback) => callback(expectedBlob)),
|
||||||
|
} as unknown as HTMLCanvasElement
|
||||||
|
vi.spyOn(document, 'createElement').mockReturnValue(canvas)
|
||||||
|
mockedCreateImage.mockResolvedValue(image)
|
||||||
|
const crop: Area = { x: 40, y: 20, width: 1000, height: 1000 }
|
||||||
|
|
||||||
|
const result = await createCroppedAvatarImage('blob:avatar', crop, 'avatar.png')
|
||||||
|
|
||||||
|
expect(result).toBe(expectedBlob)
|
||||||
|
expect(canvas.width).toBe(256)
|
||||||
|
expect(canvas.height).toBe(256)
|
||||||
|
expect(context.imageSmoothingEnabled).toBe(true)
|
||||||
|
expect(context.imageSmoothingQuality).toBe('high')
|
||||||
|
expect(context.drawImage).toHaveBeenCalledWith(image, 40, 20, 1000, 1000, 0, 0, 256, 256)
|
||||||
|
expect(canvas.toBlob).toHaveBeenCalledWith(expect.any(Function), 'image/png', 0.85)
|
||||||
|
})
|
||||||
|
})
|
||||||
57
web/app/account/(commonLayout)/account-page/avatar-image.ts
Normal file
57
web/app/account/(commonLayout)/account-page/avatar-image.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import type { Area } from 'react-easy-crop'
|
||||||
|
import { createImage, getMimeType } from '@/app/components/base/app-icon-picker/utils'
|
||||||
|
|
||||||
|
const AVATAR_IMAGE_MAX_SIZE = 256
|
||||||
|
const AVATAR_IMAGE_QUALITY = 0.85
|
||||||
|
|
||||||
|
export const getBoundedAvatarImageSize = (
|
||||||
|
crop: Pick<Area, 'width' | 'height'>,
|
||||||
|
maxSize = AVATAR_IMAGE_MAX_SIZE,
|
||||||
|
) => {
|
||||||
|
const scale = Math.min(1, maxSize / Math.max(crop.width, crop.height))
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: Math.max(1, Math.round(crop.width * scale)),
|
||||||
|
height: Math.max(1, Math.round(crop.height * scale)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createCroppedAvatarImage = async (
|
||||||
|
imageSrc: string,
|
||||||
|
pixelCrop: Area,
|
||||||
|
fileName: string,
|
||||||
|
): Promise<Blob> => {
|
||||||
|
const image = await createImage(imageSrc)
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
|
||||||
|
if (!context) throw new Error('Could not create a canvas context')
|
||||||
|
|
||||||
|
const outputSize = getBoundedAvatarImageSize(pixelCrop)
|
||||||
|
canvas.width = outputSize.width
|
||||||
|
canvas.height = outputSize.height
|
||||||
|
context.imageSmoothingEnabled = true
|
||||||
|
context.imageSmoothingQuality = 'high'
|
||||||
|
context.drawImage(
|
||||||
|
image,
|
||||||
|
pixelCrop.x,
|
||||||
|
pixelCrop.y,
|
||||||
|
pixelCrop.width,
|
||||||
|
pixelCrop.height,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
outputSize.width,
|
||||||
|
outputSize.height,
|
||||||
|
)
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
canvas.toBlob(
|
||||||
|
(file) => {
|
||||||
|
if (file) resolve(file)
|
||||||
|
else reject(new Error('Could not create a blob'))
|
||||||
|
},
|
||||||
|
getMimeType(fileName),
|
||||||
|
AVATAR_IMAGE_QUALITY,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user