mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 08:48:10 +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 { useTranslation } from 'react-i18next'
|
||||
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 { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks'
|
||||
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
|
||||
import { updateUserProfile } from '@/service/common'
|
||||
import { createCroppedAvatarImage } from './avatar-image'
|
||||
|
||||
type InputImageInfo =
|
||||
| { file: File }
|
||||
@ -106,7 +106,7 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
|
||||
handleLocalFileUpload(inputImageInfo.file)
|
||||
return
|
||||
}
|
||||
const blob = await getCroppedImg(
|
||||
const blob = await createCroppedAvatarImage(
|
||||
inputImageInfo.tempUrl,
|
||||
inputImageInfo.croppedAreaPixels,
|
||||
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