From d038d244a08d67c91cb4cc3c377f99c141501dc1 Mon Sep 17 00:00:00 2001 From: mushan0x0 Date: Mon, 18 Dec 2023 20:51:28 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20perf:=20Optimize=20the=20i?= =?UTF-8?q?mage=20upload=20size=20for=20`gpt-4-vision`=20(#669)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ♻️ refactor: `FileItem` changed to `ImageFileItem` * ⚡️ perf: Optimize the image upload size for `gpt-4-vision` * 🔧 test: add Test Configuration * :test_tube: test: add case * :pencil2: chore: typos --------- Co-authored-by: wuxh --- package.json | 3 +- src/components/FileList/EditableFileList.tsx | 4 +- .../{FileItem.tsx => ImageFileItem.tsx} | 4 +- src/components/FileList/index.tsx | 6 +- src/services/file.ts | 33 +++++++++++ src/utils/compressImage.test.ts | 59 +++++++++++++++++++ src/utils/compressImage.ts | 29 +++++++++ tests/setup.ts | 1 + vitest.config.ts | 4 ++ 9 files changed, 135 insertions(+), 8 deletions(-) rename src/components/FileList/{FileItem.tsx => ImageFileItem.tsx} (94%) create mode 100644 src/utils/compressImage.test.ts create mode 100644 src/utils/compressImage.ts diff --git a/package.json b/package.json index 8d0125765435..ce42b6836bc4 100644 --- a/package.json +++ b/package.json @@ -174,7 +174,8 @@ "typescript": "^5", "unified": "^11", "unist-util-visit": "^5", - "vitest": "0.34.6" + "vitest": "0.34.6", + "vitest-canvas-mock": "^0.3.3" }, "publishConfig": { "access": "public", diff --git a/src/components/FileList/EditableFileList.tsx b/src/components/FileList/EditableFileList.tsx index 0456ec968d37..37d9193240ad 100644 --- a/src/components/FileList/EditableFileList.tsx +++ b/src/components/FileList/EditableFileList.tsx @@ -3,7 +3,7 @@ import { useResponsive } from 'antd-style'; import { memo } from 'react'; import { Flexbox } from 'react-layout-kit'; -import FileItem from '@/components/FileList/FileItem'; +import ImageFileItem from '@/components/FileList/ImageFileItem'; interface EditableFileListProps { alwaysShowClose?: boolean; @@ -24,7 +24,7 @@ const EditableFileList = memo( > {items.map((i) => ( - + ))} diff --git a/src/components/FileList/FileItem.tsx b/src/components/FileList/ImageFileItem.tsx similarity index 94% rename from src/components/FileList/FileItem.tsx rename to src/components/FileList/ImageFileItem.tsx index eeb99ce47bf3..9d22d725f2fc 100644 --- a/src/components/FileList/FileItem.tsx +++ b/src/components/FileList/ImageFileItem.tsx @@ -34,7 +34,7 @@ interface FileItemProps { onClick?: () => void; style?: CSSProperties; } -const FileItem = memo(({ editable, id, alwaysShowClose }) => { +const ImageFileItem = memo(({ editable, id, alwaysShowClose }) => { const [useFetchFile, removeFile] = useFileStore((s) => [s.useFetchFile, s.removeFile]); const IMAGE_SIZE = editable ? MIN_IMAGE_SIZE : '100%'; const { data, isLoading } = useFetchFile(id); @@ -74,4 +74,4 @@ const FileItem = memo(({ editable, id, alwaysShowClose }) => { ); }); -export default FileItem; +export default ImageFileItem; diff --git a/src/components/FileList/index.tsx b/src/components/FileList/index.tsx index 7c438158f757..197c9582dd66 100644 --- a/src/components/FileList/index.tsx +++ b/src/components/FileList/index.tsx @@ -6,7 +6,7 @@ import { Flexbox } from 'react-layout-kit'; import { MAX_SIZE_DESKTOP, MAX_SIZE_MOBILE } from '@/components/FileList/style'; import FileGrid from './FileGrid'; -import FileItem from './FileItem'; +import ImageFileItem from './ImageFileItem'; interface FileListProps { items: string[]; @@ -44,13 +44,13 @@ const FileList = memo(({ items }) => { {firstRow.map((i) => ( - + ))} {lastRow.length > 0 && ( 2 ? 3 : lastRow.length} gap={gap} max={max}> {lastRow.map((i) => ( - + ))} )} diff --git a/src/services/file.ts b/src/services/file.ts index da34f793cd57..e75f1ec8b192 100644 --- a/src/services/file.ts +++ b/src/services/file.ts @@ -1,14 +1,47 @@ import { FileModel } from '@/database/models/file'; import { DB_File } from '@/database/schemas/files'; import { FilePreview } from '@/types/files'; +import compressImage from '@/utils/compressImage'; class FileService { + private isImage(fileType: string) { + const imageRegex = /^image\//; + return imageRegex.test(fileType); + } async uploadFile(file: DB_File) { + // 跳过图片上传测试 + const isTestData = file.size === 1; + if (this.isImage(file.fileType) && !isTestData) { + return this.uploadImageFile(file); + } // save to local storage // we may want to save to a remote server later return FileModel.create(file); } + async uploadImageFile(file: DB_File) { + // 加载图片 + const url = file.url || URL.createObjectURL(new Blob([file.data])); + const img = new Image(); + img.src = url; + await (() => + new Promise((resolve) => { + img.addEventListener('load', resolve); + }))(); + + // 压缩图片 + const fileType = 'image/webp'; + const base64String = compressImage({ + img, + type: fileType, + }); + const binaryString = atob(base64String.split('base64,')[1]); + const uint8Array = Uint8Array.from(binaryString, (char) => char.charCodeAt(0)); + file.data = uint8Array.buffer; + + return FileModel.create(file); + } + async removeFile(id: string) { return FileModel.delete(id); } diff --git a/src/utils/compressImage.test.ts b/src/utils/compressImage.test.ts new file mode 100644 index 000000000000..4d60d51d7a42 --- /dev/null +++ b/src/utils/compressImage.test.ts @@ -0,0 +1,59 @@ +import compressImage from './compressImage'; + +const getContextSpy = vi.spyOn(global.HTMLCanvasElement.prototype, 'getContext'); +const drawImageSpy = vi.spyOn(CanvasRenderingContext2D.prototype, 'drawImage'); + +beforeEach(() => { + getContextSpy.mockClear(); + drawImageSpy.mockClear(); +}); + +describe('compressImage', () => { + it('should compress image with maxWidth', () => { + const img = document.createElement('img'); + img.width = 3000; + img.height = 2000; + + const r = compressImage({ img }); + + expect(r).toMatch(/^data:image\/webp;base64,/); + + expect(getContextSpy).toBeCalledTimes(1); + expect(getContextSpy).toBeCalledWith('2d'); + + expect(drawImageSpy).toBeCalledTimes(1); + expect(drawImageSpy).toBeCalledWith(img, 0, 0, 3000, 2000, 0, 0, 2160, 1440); + }); + + it('should compress image with maxHeight', () => { + const img = document.createElement('img'); + img.width = 2000; + img.height = 3000; + + const r = compressImage({ img }); + + expect(r).toMatch(/^data:image\/webp;base64,/); + + expect(getContextSpy).toBeCalledTimes(1); + expect(getContextSpy).toBeCalledWith('2d'); + + expect(drawImageSpy).toBeCalledTimes(1); + expect(drawImageSpy).toBeCalledWith(img, 0, 0, 2000, 3000, 0, 0, 1440, 2160); + }); + + it('should not compress image', () => { + const img = document.createElement('img'); + img.width = 2000; + img.height = 2000; + + const r = compressImage({ img }); + + expect(r).toMatch(/^data:image\/webp;base64,/); + + expect(getContextSpy).toBeCalledTimes(1); + expect(getContextSpy).toBeCalledWith('2d'); + + expect(drawImageSpy).toBeCalledTimes(1); + expect(drawImageSpy).toBeCalledWith(img, 0, 0, 2000, 2000, 0, 0, 2000, 2000); + }); +}); diff --git a/src/utils/compressImage.ts b/src/utils/compressImage.ts new file mode 100644 index 000000000000..14d9acb4b65e --- /dev/null +++ b/src/utils/compressImage.ts @@ -0,0 +1,29 @@ +const compressImage = ({ img, type = 'image/webp' }: { img: HTMLImageElement; type?: string }) => { + // 设置最大宽高 + const maxWidth = 2160; + const maxHeight = 2160; + let width = img.width; + let height = img.height; + + if (width > height && width > maxWidth) { + // 如果图片宽度大于高度且大于最大宽度限制 + width = maxWidth; + height = Math.round((maxWidth / img.width) * img.height); + } else if (height > width && height > maxHeight) { + // 如果图片高度大于宽度且大于最大高度限制 + height = maxHeight; + width = Math.round((maxHeight / img.height) * img.width); + } + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; + + canvas.width = width; + canvas.height = height; + + ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, width, height); + + return canvas.toDataURL(type); +}; + +export default compressImage; diff --git a/tests/setup.ts b/tests/setup.ts index 7f52958a7169..95d9ccfbc702 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -5,6 +5,7 @@ import { theme } from 'antd'; // refs: https://github.com/dumbmatter/fakeIndexedDB#dexie-and-other-indexeddb-api-wrappers import 'fake-indexeddb/auto'; import React from 'react'; +import 'vitest-canvas-mock'; // remove antd hash on test theme.defaultConfig.hashed = false; diff --git a/vitest.config.ts b/vitest.config.ts index 2b804fad7962..18e5acbb22cf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -16,6 +16,10 @@ export default defineConfig({ provider: 'v8', reporter: ['text', 'json', 'lcov', 'text-summary'], }, + deps: { + inline: ['vitest-canvas-mock'], + }, + // threads: false, environment: 'jsdom', globals: true, setupFiles: './tests/setup.ts',