perf(web): downsample static account avatars (#41655)

This commit is contained in:
yyh 2026-09-02 09:02:46 +00:00 committed by GitHub
parent 98090d60b3
commit 02855848ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 212 additions and 2 deletions

View File

@ -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,

View File

@ -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)
})
})

View File

@ -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)
})
})

View 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,
)
})
}